diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..f37ee25 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +language: en-US +early_access: true +enable_free_tier: true +reviews: + profile: chill + poem: false + pre_merge_checks: + docstrings: + mode: off + finishing_touches: + docstrings: + enabled: false + tools: + phpstan: + enabled: false +issue_enrichment: + auto_enrich: + enabled: false diff --git a/.github/api/createCommitOnBranch.gql b/.github/api/createCommitOnBranch.gql deleted file mode 100644 index 63302d0..0000000 --- a/.github/api/createCommitOnBranch.gql +++ /dev/null @@ -1,25 +0,0 @@ -mutation ( - $githubRepository: String! - $branchName: String! - $expectedHeadOid: GitObjectID! - $commitMessage: String! - $pluginFile: FileAddition! - $previewFile: FileAddition! - $trunkFile: FileAddition! -) { - createCommitOnBranch( - input: { - branch: { - repositoryNameWithOwner: $githubRepository - branchName: $branchName - } - message: { headline: $commitMessage } - fileChanges: { additions: [$pluginFile, $previewFile, $trunkFile] } - expectedHeadOid: $expectedHeadOid - } - ) { - commit { - url - } - } -} diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..fb4dd87 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,29 @@ +categories: + - title: "🚀 Features" + labels: + - "feat" + - title: "🐛 Bug Fixes" + labels: + - "fix" + - title: "🧰 Maintenance" + labels: + - "chore" + - "refactor" +change-template: "- $TITLE ([#$NUMBER]($URL))" +category-template: "### $TITLE" +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. +template: | + $CHANGES +prerelease: "true" +include-pre-releases: "true" +latest: "false" +exclude-labels: + - "docs" + - "test" + - "ci" + - "perf" + - "refactor" + - "translation" +replacers: + - search: '/^(feat|fix|chore|refactor)(\(.+\))?: /' + replace: "" diff --git a/.github/workflows/jinja.yml b/.github/workflows/jinja.yml deleted file mode 100644 index 9cdabcc..0000000 --- a/.github/workflows/jinja.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Template plugin files - -on: - push: - branches-ignore: - - trunk - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - - name: Template - run: | - pip install jinja-cli - cd tools - ./build-plugin.sh - - - name: Commit plugin file - run: | - gh api graphql \ - -F githubRepository=$GITHUB_REPOSITORY \ - -F branchName=$BRANCH \ - -F expectedHeadOid=$(git rev-parse HEAD) \ - -F commitMessage="ci: template plugin files" \ - -F pluginFile[path]="plugin/tailscale.plg" -F pluginFile[contents]=$(base64 -w0 plugin/tailscale.plg) \ - -F previewFile[path]="plugin/tailscale-preview.plg" -F previewFile[contents]=$(base64 -w0 plugin/tailscale-preview.plg) \ - -F trunkFile[path]="plugin/tailscale-trunk.plg" -F trunkFile[contents]=$(base64 -w0 plugin/tailscale-trunk.plg) \ - -F 'query=@.github/api/createCommitOnBranch.gql' - env: - GH_TOKEN: ${{ github.token }} - BRANCH: ${{ github.ref }} diff --git a/.github/workflows/latest-tag.yml b/.github/workflows/latest-tag.yml deleted file mode 100644 index 1cc313b..0000000 --- a/.github/workflows/latest-tag.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Add main tag to new release -on: - release: - types: [published, edited] - -jobs: - run: - name: Run local action - runs-on: ubuntu-latest - if: github.event.release.draft == false - steps: - - name: Checkout repository - uses: actions/checkout@master - - - name: Get full release object - run: | - RELEASE_JSON=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/releases/${{ github.event.release.id }}") - - echo "Latest release name: $RELEASE_JSON" - - - name: Run latest-tag - uses: EndBug/latest-tag@v1 - if: | - github.event.release.prerelease == false - with: - ref: main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Run latest-tag - uses: EndBug/latest-tag@v1 - with: - ref: preview - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/commitlint.yml b/.github/workflows/lint.yml similarity index 55% rename from .github/workflows/commitlint.yml rename to .github/workflows/lint.yml index 5e692d7..f1924df 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/lint.yml @@ -3,14 +3,37 @@ name: Commit Quality on: push: branches: - - trunk + - main pull_request: jobs: + php-cs-fixer: + name: PHP-CS-Fixer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: PHP-CS-Fixer + uses: docker://oskarstark/php-cs-fixer-ga + with: + args: --diff --dry-run + + phpstan: + name: PHPStan Analysis + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: php-actions/composer@v6 + with: + working_dir: "src/usr/local/php/unraid-tailscale-utils" + php_extensions: gmp + - run: "vendor/bin/phpstan" + commitlint: + name: Commitlint runs-on: ubuntu-22.04 steps: - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 @@ -19,7 +42,7 @@ jobs: npm install -g commitlint npm install -g "@commitlint/config-conventional" - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml new file mode 100644 index 0000000..99ee463 --- /dev/null +++ b/.github/workflows/pr-label.yml @@ -0,0 +1,14 @@ +name: PR Conventional Commit Validation + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + steps: + - name: PR Conventional Commit Validation + uses: ytanikin/pr-conventional-commits@b72758283dcbee706975950e96bc4bf323a8d8c0 + with: + task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]' diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..eb039c7 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,29 @@ +name: Release Drafter + +on: + push: + branches: + - trunk + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + contents: write + pull-requests: read + runs-on: ubuntu-latest + steps: + # Calculate version string in format YYYY.MM.DD.HHMM + - name: Set version string + id: set_version + run: TZ=UTC date +'%Y.%m.%d.%H%M' | xargs -I{} echo "version={}" >> $GITHUB_ENV + + - uses: dkaser/release-drafter@8bc802e5816eff4cc92ae7ce2a6f5f09c284bf0d + with: + version: ${{ env.version }} + name: ${{ env.version }} + tag: ${{ env.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e8642c2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + release: + types: + - prereleased + - released + - published + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: dkaser/unraid-plugin-release-action@v1 + id: release_action + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + plg_branch: trunk + build_prereleases: true + ssh_key: ${{ secrets.DEPLOY_KEY }} + composer_dir: src/usr/local/php/unraid-tailscale-utils/ + php_extensions: gmp + + # Print out the commit SHA + - name: Print commit SHA + run: echo "Commit SHA ${{ steps.release_action.outputs.commit_long_sha }}" + + # Update preview tag using git + - name: Update preview tag + run: | + cd ${{ github.workspace }}/main + git tag -f preview ${{ steps.release_action.outputs.commit_long_sha }} + git push origin preview --force + + # Update main tag if not a prerelease + - name: Update main tag + if: github.event.release.prerelease == false + run: | + cd ${{ github.workspace }}/main + git tag -f main ${{ steps.release_action.outputs.commit_long_sha }} + git push origin main --force + + - name: Notify Discord + uses: dkaser/discord-webhook-notify@tailscale + with: + webhookUrl: ${{ secrets.TAILSCALE_DISCORD_WEBHOOK }} + username: Tailscale Bot + avatarUrl: https://raw.githubusercontent.com/unraid/unraid-tailscale/trunk/logo.png + thumbnailUrl: https://raw.githubusercontent.com/unraid/unraid-tailscale/trunk/logo.png + severity: info + color: ${{ github.event.release.prerelease && '#ffff00' || '#00ff00' }} + title: New Update Available + description: | + ## Tailscale Plugin Update : ${{ github.event.release.name }} + + ${{ github.event.release.body }} + fields: | + [ + {"name": "Channel", "value": ${{ toJson(github.event.release.prerelease && 'Preview :test_tube:' || 'Release') }}, "inline": true}, + {"name": "Tag", "value": ${{ toJson(github.event.release.tag_name) }}, "inline": true}, + {"name": "Github Release", "value": ${{ toJson(format('[Link]({0})', github.event.release.html_url)) }}, "inline": true} + ] diff --git a/.github/workflows/translate-download.yml b/.github/workflows/translate-download.yml new file mode 100644 index 0000000..8f59b84 --- /dev/null +++ b/.github/workflows/translate-download.yml @@ -0,0 +1,31 @@ +name: Download translations + +on: + schedule: + - cron: "30 2 * * *" + workflow_dispatch: + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v2 + with: + upload_sources: false + download_translations: true + push_translations: true + commit_message: "chore: update translations from Crowdin" + create_pull_request: true + pull_request_title: "chore: update translations from Crowdin" + pull_request_labels: "translation" + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/translate.yml b/.github/workflows/translate.yml new file mode 100644 index 0000000..e174065 --- /dev/null +++ b/.github/workflows/translate.yml @@ -0,0 +1,22 @@ +name: Upload for translation + +on: + push: + branches: + - "trunk" + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v2 + with: + upload_sources: true + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/update-tailscale.yml b/.github/workflows/update-tailscale.yml new file mode 100644 index 0000000..6a651ef --- /dev/null +++ b/.github/workflows/update-tailscale.yml @@ -0,0 +1,86 @@ +name: Tailscale updater + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-tailscale: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: trunk + + - name: Determine versions + id: compare + run: | + latest_tarball="$(curl -fsSL https://pkgs.tailscale.com/stable/?mode=json | jq -r '.TarballsVersion')" + if [ -z "$latest_tarball" ]; then + echo "Error: Failed to fetch latest Tailscale version" >&2 + exit 1 + fi + current_full="$(jq -r '.tailscaleVersion' plugin/plugin.json)" + current_tarball="${current_full#tailscale_}" + current_tarball="${current_tarball%_amd64}" + echo "latest=${latest_tarball}" >> "$GITHUB_OUTPUT" + echo "current=${current_tarball}" >> "$GITHUB_OUTPUT" + if [ "$latest_tarball" != "$current_tarball" ]; then + echo "update=true" >> "$GITHUB_OUTPUT" + else + echo "update=false" >> "$GITHUB_OUTPUT" + fi + + - name: Stop when up to date + if: steps.compare.outputs.update != 'true' + run: echo "Tailscale is already up to date." + + - name: Update plugin metadata + if: steps.compare.outputs.update == 'true' + id: plugin + run: | + new_full="tailscale_${{ steps.compare.outputs.latest }}_amd64" + sha_url="https://pkgs.tailscale.com/stable/tailscale_${{ steps.compare.outputs.latest }}_amd64.tgz.sha256" + new_sha="$(curl -fsSL "$sha_url" | cut -d' ' -f1)" + tmp="$(mktemp)" + jq --arg version "$new_full" --arg sha "$new_sha" \ + '.tailscaleVersion=$version | .tailscaleSHA256=$sha' \ + plugin/plugin.json > "$tmp" + mv "$tmp" plugin/plugin.json + echo "full_version=$new_full" >> "$GITHUB_OUTPUT" + echo "sha=$new_sha" >> "$GITHUB_OUTPUT" + + - name: Commit and push branch + if: steps.compare.outputs.update == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B chore/tailscale-update + git add plugin/plugin.json + if git commit -m "chore: update Tailscale to ${{ steps.compare.outputs.latest }}"; then + git push --force origin chore/tailscale-update + echo "commit_made=true" >> $GITHUB_ENV + else + echo "No changes to commit." + echo "commit_made=false" >> $GITHUB_ENV + fi + + - name: Create or update PR + if: steps.compare.outputs.update == 'true' && env.commit_made == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --title "chore: update Tailscale to ${{ steps.compare.outputs.latest }}" \ + --body "Automated update via workflow." \ + --head chore/tailscale-update \ + --base trunk \ + --label chore \ + || gh pr edit chore/tailscale-update \ + --title "chore: update Tailscale to ${{ steps.compare.outputs.latest }}" \ + --body "Automated update via workflow." diff --git a/.gitignore b/.gitignore index eab10c5..4640046 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store ._.DS_Store -.php-cs-fixer.cache \ No newline at end of file +.php-cs-fixer.cache +vendor \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..7ebe048 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,45 @@ +files() + ->in(__DIR__) + ->name('*.php') + ->name('*.page') + ->exclude(['vendor']) +; + +$config = new PhpCsFixer\Config(); +return $config + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + 'no_empty_comment' => true, + 'multiline_comment_opening_closing' => true, + 'single_line_comment_spacing' => true, + 'single_line_comment_style' => true, + 'heredoc_indentation' => true, + 'include' => true, + 'no_alternative_syntax' => true, + 'single_space_around_construct' => true, + 'binary_operator_spaces' => ['default' => 'align_single_space_minimal'], + 'concat_space' => ['spacing' => 'one'], + 'linebreak_after_opening_tag' => true, + 'increment_style' => ['style' => 'post'], + 'logical_operators' => true, + 'no_useless_concat_operator' => ['juggle_simple_strings' => true], + 'not_operator_with_space' => true, + 'object_operator_without_whitespace' => true, + 'standardize_increment' => true, + 'standardize_not_equals' => true, + 'no_useless_return' => true, + 'no_empty_statement' => true, + 'semicolon_after_instruction' => true, + 'explicit_string_variable' => true, + 'simple_to_complex_string_variable' => true, + 'method_chaining_indentation' => true, + 'no_extra_blank_lines' => ['tokens' => ['attribute', 'break', 'case', 'continue', 'curly_brace_block', 'default', 'extra', 'parenthesis_brace_block', 'return', 'square_brace_block', 'switch', 'throw', 'use']], + ]) + ->setIndent(' ') + ->setLineEnding("\n") + ->setFinder($finder) +; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5bf2d43 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing to unraid-tailscale-utils + +Thank you for your interest in contributing to **unraid-tailscale-utils**! + +## How to Contribute + +- **Bug Reports & Feature Requests:** + Please open an issue describing your bug or feature request with as much detail as possible. + +- **Pull Requests:** + 1. Fork the repository and create your branch from `main`. + 2. Make your changes, following the existing code style. + 3. Add or update tests as appropriate. + 4. Ensure your code passes all checks (see below). + 5. Submit a pull request with a clear description of your changes. + +## Localization + +New strings should be added to `src/usr/local/emhttp/plugins/tailscale/locales/en_US.json`. + +Translations are managed via Crowdin (https://translate.edac.dev/) + +## Code Quality & Checks + +This repository uses automated code checks via GitHub Actions ([.github/workflows/lint.yml](.github/workflows/lint.yml)): + +- **Static Analysis:** + Run `vendor/bin/phpstan` after running `./composer install` in the repository root. + +- **Code Formatting:** + Run `vendor/bin/php-cs-fixer fix` to automatically apply formatting rules. + +- **Commit Message Linting:** + All commits must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. + Example: + ``` + feat: add advanced log filtering + fix: resolve colorization bug in syslog view + ``` + +These checks are run automatically on every push and pull request. Please ensure your code passes locally before submitting. + +## License + +By contributing to this repository, you agree that your contributions will be licensed under the [GNU General Public License v3.0 or later](LICENSE). diff --git a/README.md b/README.md index 6f921f8..c96777d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,30 @@ # unraid-tailscale -![Current Release](https://img.shields.io/github/v/release/dkaser/unraid-tailscale) -![Last Release](https://img.shields.io/github/release-date/dkaser/unraid-tailscale) -![Last Commit](https://img.shields.io/github/last-commit/dkaser/unraid-tailscale/trunk) -![License](https://img.shields.io/github/license/dkaser/unraid-tailscale) -![Issues](https://img.shields.io/github/issues-raw/dkaser/unraid-tailscale) -![Sponsors](https://img.shields.io/github/sponsors/dkaser) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) +[![GitHub Releases](https://img.shields.io/github/v/release/unraid/unraid-tailscale)](https://github.com/unraid/unraid-tailscale/releases) +[![Last Commit](https://img.shields.io/github/last-commit/unraid/unraid-tailscale)](https://github.com/unraid/unraid-tailscale/commits/trunk/) +[![Code Style: PHP-CS-Fixer](https://img.shields.io/badge/code%20style-php--cs--fixer-brightgreen.svg)](https://github.com/FriendsOfPHP/PHP-CS-Fixer) +![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/unraid/unraid-tailscale/total) +![GitHub Downloads (all assets, latest release)](https://img.shields.io/github/downloads/unraid/unraid-tailscale/latest/total) +## Features +- Easy Tailscale installation and management on Unraid +- Helper scripts for authentication and status +- Example configurations for common use cases -## Overview +## Development -This installs Tailscale as an Unraid plugin. +### Requirements -Taildrop can be enabled by specifying a destination folder in the plugin configuration. +- [Composer](https://getcomposer.org/) for dependency management -Support is available on the Unraid forums: https://forums.unraid.net/topic/136889-plugin-tailscale/ +### Testing +1. Clone the repository. +2. Run `./composer install` to install dependencies. +3. For local testing, copy the contents of `src/` (except for the `install` directory) to the root of the Unraid test system. + +### Contributing +Pull requests and issues are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines, including code checks, commit message conventions, and licensing. You can also open an issue to discuss your idea. + +## License +This project is licensed under the GNU General Public License v3.0 or later. See [LICENSE](LICENSE) for details. diff --git a/composer b/composer new file mode 100755 index 0000000..cfc8363 --- /dev/null +++ b/composer @@ -0,0 +1,3 @@ +#!/bin/bash + +composer --working-dir src/usr/local/php/unraid-tailscale-utils/ "$@" \ No newline at end of file diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..3215382 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,9 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN + +preserve_hierarchy: true + +files: + - source: /src/usr/local/emhttp/plugins/tailscale/locales/en_US.json + dest: /unraid-tailscale-utils/en_US.json + translation: /src/usr/local/emhttp/plugins/tailscale/locales/%locale_with_underscore%.json diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..796848c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + level: 9 + fileExtensions: + - php + - page + paths: + - src + excludePaths: + - */vendor/* \ No newline at end of file diff --git a/plugin/plugin.j2 b/plugin/plugin.j2 new file mode 100644 index 0000000..fe05795 --- /dev/null +++ b/plugin/plugin.j2 @@ -0,0 +1,178 @@ + + + +{% set pluginDirectory = "/usr/local/emhttp/plugins/tailscale" -%} +{% set configDirectory = "/boot/config/plugins/tailscale" -%} +{% set version = env['PLUGIN_VERSION'] -%} +{% if env['PLUGIN_RELEASE'] == "-preview" -%} +{% set branch = "preview" -%} +{% else -%} +{% set branch = "main" -%} +{% endif -%} + + + + + + + + + + + + + + +https://pkgs.tailscale.com/stable/{{ tailscaleVersion }}.tgz +{{ tailscaleSHA256 }} + + + +https://github.com/{{ env['GITHUB_REPOSITORY'] }}/releases/download/{{ env['PLUGIN_VERSION'] }}/{{ package_name }}-{{ env['PLUGIN_VERSION'] }}-noarch-1.txz +{{ env['PLUGIN_CHECKSUM'] }} + + + + + + {{ configDirectory }}/.gitignore + +ln -s {{ pluginDirectory }}/bin/tailscale /usr/local/sbin/tailscale +ln -s {{ pluginDirectory }}/bin/tailscaled /usr/local/sbin/tailscaled + +# remove other branches (e.g., if switching from main to preview) +{% if branch != 'main' -%} +rm -f /boot/config/plugins/tailscale.plg +rm -f /var/log/plugins/tailscale.plg +{% endif -%} +{% if branch != 'preview' -%} +rm -f /boot/config/plugins/tailscale-preview.plg +rm -f /var/log/plugins/tailscale-preview.plg +{% endif -%} +{% if branch != 'trunk' -%} +rm -f /boot/config/plugins/tailscale-trunk.plg +rm -f /var/log/plugins/tailscale-trunk.plg +{% endif %} + +{% if branch != 'main' -%} +# Update plugin name for non-main branches +sed -i "s/Tailscale\*\*/Tailscale ({{ branch.capitalize() }})**/" {{ pluginDirectory }}/README.md +{% endif %} + +# start tailscaled +{{ pluginDirectory }}/restart.sh + +# Bash completion +tailscale completion bash > /etc/bash_completion.d/tailscale + +# cleanup old versions +rm -f $(ls /boot/config/plugins/{{ name }}/{{ package_name }}-*.txz 2>/dev/null | grep -v '{{ env['PLUGIN_VERSION'] }}') +rm -f $(ls /boot/config/plugins/{{ name }}/*.tgz 2>/dev/null | grep -v '{{ tailscaleVersion }}') + +# check to see if the state file has been backed up to Unraid Connect +if [ -d "/boot/.git" ]; then + if git --git-dir /boot/.git log --all --name-only --diff-filter=A -- config/plugins/tailscale/state/tailscaled.state | grep -q . ; then + echo "******************************" + echo "* WARNING *" + echo "******************************" + echo " " + echo "The Tailscale state file has been backed up to Unraid Connect via Flash backup." + echo " " + echo "To remove this backup, please perform the following steps:" + echo "1. Go to Settings -> Management Access". + echo "2. Under Unraid Connect, deactivate flash backup. Select the option to also delete cloud backup." + echo "3. Reactivate flash backup." + + /usr/local/emhttp/webGui/scripts/notify -l '/Settings/ManagementAccess' -i 'alert' -e 'Tailscale State' -s 'Tailscale state backed up to Unraid connect.' -d 'The Tailscale state file has been backed up to Unraid connect. This is a potential security risk. From the Management Settings page, deactivate flash backup and delete cloud backups, then reactivate flash backup.' + fi +fi + +echo "" +echo "----------------------------------------------------" +echo " {{ name }} has been installed." +echo " Version: {{ env['PLUGIN_VERSION'] }}" +echo "----------------------------------------------------" +echo "" +]]> + + + + + + +/dev/null + +rm /usr/local/sbin/tailscale +rm /usr/local/sbin/tailscaled + +removepkg unraid-tailscale-utils + +rm -rf {{ pluginDirectory }} +rm -f {{ configDirectory }}/*.tgz +rm -f {{ configDirectory }}/*.txz +]]> + + + + diff --git a/plugin/plugin.json b/plugin/plugin.json new file mode 100644 index 0000000..8843547 --- /dev/null +++ b/plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "tailscale", + "package_name": "unraid-tailscale-utils", + "author": "Derek Kaser", + "min": "7.0.0", + "support": "https://forums.unraid.net/topic/136889-plugin-tailscale/", + "launch": "Settings/Tailscale", + "tailscaleVersion": "tailscale_1.96.4_amd64", + "tailscaleSHA256": "a1cba18826b1f91cb25ef7f5b8259b5258339b42db7867af9269e21829ea78cc" +} diff --git a/plugin/tailscale-preview.plg b/plugin/tailscale-preview.plg index 4a77bad..a13a76b 100644 --- a/plugin/tailscale-preview.plg +++ b/plugin/tailscale-preview.plg @@ -4,44 +4,90 @@ - -https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz -c91eb43a92c209108bfaf1237696ac2089cc3d8fcf35d570d348cbfb19d8fb31 + + + + + + + +https://pkgs.tailscale.com/stable/tailscale_1.96.4_amd64.tgz +a1cba18826b1f91cb25ef7f5b8259b5258339b42db7867af9269e21829ea78cc - -https://github.com/unraid/unraid-tailscale-utils/releases/download/4.1.0/unraid-tailscale-utils-4.1.0-noarch-1.txz -97b01db93921e0b2ee58c7b47fa6246eff2c81c872f70043cf85bf33f6572a15 + +https://github.com/unraid/unraid-tailscale/releases/download/2026.04.19.1536/unraid-tailscale-utils-2026.04.19.1536-noarch-1.txz +cb7b5cd2297f8103f147cd37cfcd1afa8df022f0185a9f7c0b6ae850ba05fa31 - - - /boot/config/plugins/tailscale/.gitignore - -ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale -ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled - -mkdir -p /var/local/emhttp/plugins/tailscale -echo "VERSION=2025.07.07" > /var/local/emhttp/plugins/tailscale/tailscale.ini -echo "BRANCH=trunk" >> /var/local/emhttp/plugins/tailscale/tailscale.ini - -# remove other branches (e.g., if switching from main to preview) -rm -f /boot/config/plugins/tailscale.plg -rm -f /var/log/plugins/tailscale.plg -rm -f /boot/config/plugins/tailscale-preview.plg -rm -f /var/log/plugins/tailscale-preview.plg - - -# Update plugin name for non-main branches -sed -i "s/Tailscale\*\*/Tailscale (Trunk)**/" /usr/local/emhttp/plugins/tailscale/README.md - - -# start tailscaled -/usr/local/emhttp/plugins/tailscale/restart.sh - -# Bash completion -tailscale completion bash > /etc/bash_completion.d/tailscale - -# cleanup old versions -rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz -rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '4.1.0') -rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null) -rm -f $(ls /boot/config/plugins/tailscale/*.tgz 2>/dev/null | grep -v 'tailscale_1.84.0_amd64') - -# check to see if the state file has been backed up to Unraid Connect -if [ -d "/boot/.git" ]; then - if git --git-dir /boot/.git log --all --name-only --diff-filter=A -- config/plugins/tailscale/state/tailscaled.state | grep -q . ; then - echo "******************************" - echo "* WARNING *" - echo "******************************" - echo " " - echo "The Tailscale state file has been backed up to Unraid Connect via Flash backup." - echo " " - echo "To remove this backup, please perform the following steps:" - echo "1. Go to Settings -> Management Access". - echo "2. Under Unraid Connect, deactivate flash backup. Select the option to also delete cloud backup." - echo "3. Reactivate flash backup." - - /usr/local/emhttp/webGui/scripts/notify -l '/Settings/ManagementAccess' -i 'alert' -e 'Tailscale State' -s 'Tailscale state backed up to Unraid connect.' -d 'The Tailscale state file has been backed up to Unraid connect. This is a potential security risk. From the Management Settings page, deactivate flash backup and delete cloud backups, then reactivate flash backup.' - fi -fi - -echo "" -echo "----------------------------------------------------" -echo " tailscale has been installed." -echo " Version: 2025.07.07" -echo "----------------------------------------------------" -echo "" -]]> - - - - - - -/dev/null - -rm /usr/local/sbin/tailscale -rm /usr/local/sbin/tailscaled - -removepkg unraid-tailscale-utils - -rm -rf /usr/local/emhttp/plugins/tailscale -rm -f /boot/config/plugins/tailscale/*.tgz -rm -f /boot/config/plugins/tailscale/*.txz -]]> - - - - diff --git a/plugin/tailscale.plg b/plugin/tailscale.plg index 503fad3..a75dccb 100644 --- a/plugin/tailscale.plg +++ b/plugin/tailscale.plg @@ -4,44 +4,91 @@ - -https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz -c91eb43a92c209108bfaf1237696ac2089cc3d8fcf35d570d348cbfb19d8fb31 + + + + + + + +https://pkgs.tailscale.com/stable/tailscale_1.96.2_amd64.tgz +caa2af3dec57db3c68e47ed927dcdf6d95376f0d82f8dd0bb91ff4228b7f7401 - -https://github.com/unraid/unraid-tailscale-utils/releases/download/4.1.0/unraid-tailscale-utils-4.1.0-noarch-1.txz -97b01db93921e0b2ee58c7b47fa6246eff2c81c872f70043cf85bf33f6572a15 + +https://github.com/unraid/unraid-tailscale/releases/download/2026.03.20.0222/unraid-tailscale-utils-2026.03.20.0222-noarch-1.txz +c19f607bdb3c0dc2ac4c601201c337ff2844d00225608dac496f080342c7b673 +getTailscaleLockWarning()); ?> +getTailscaleNetbiosWarning()); ?> +getKeyExpirationWarning()); ?> + +

+

+

+ + + + +
+ +
\ No newline at end of file diff --git a/src/usr/local/emhttp/plugins/tailscale/include/common.php b/src/usr/local/emhttp/plugins/tailscale/include/common.php new file mode 100644 index 0000000..e165648 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/common.php @@ -0,0 +1,29 @@ +. +*/ + +namespace Tailscale; + +define(__NAMESPACE__ . "\PLUGIN_ROOT", dirname(dirname(__FILE__))); +define(__NAMESPACE__ . "\PLUGIN_NAME", "tailscale-utils"); + +// @phpstan-ignore requireOnce.fileNotFound +require_once "/usr/local/php/unraid-tailscale-utils/vendor/autoload.php"; + +$utils = new Utils(PLUGIN_NAME); +$utils->setPHPDebug(); diff --git a/src/usr/local/emhttp/plugins/tailscale/include/data/Config.php b/src/usr/local/emhttp/plugins/tailscale/include/data/Config.php new file mode 100644 index 0000000..9834710 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/data/Config.php @@ -0,0 +1,405 @@ +. +*/ + +use EDACerton\PluginUtils\Translator; + +try { + require_once dirname(dirname(__FILE__)) . "/common.php"; + + if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); + } + + $tr = $tr ?? new Translator(PLUGIN_ROOT); + $utils = $utils ?? new Utils(PLUGIN_NAME); + + $tailscaleConfig = $tailscaleConfig ?? new Config(); + + if ( ! $tailscaleConfig->Enable) { + echo("{}"); + return; + } + + // Avoid problems if the user changes the WebGUI port after enabling funnel + System::checkFunnelPort($tailscaleConfig); + + $localAPI = new LocalAPI(); + $tailscaleInfo = $tailscaleInfo ?? new Info($tr); + + switch ($_POST['action']) { + case 'get': + $connectionRows = ""; + $configRows = ""; + $routes = "
"; + $config = "
"; + + if ($tailscaleInfo->needsLogin()) { + $connectionRows = "{$tr->tr("needs_login")}"; + } else { + $tailscaleStatusInfo = $tailscaleInfo->getStatusInfo(); + $tailscaleConInfo = $tailscaleInfo->getConnectionInfo(); + + $acceptDNSButton = $tailscaleInfo->acceptsDNS() ? "" : + ( + $tailscaleConfig->AllowDNS ? "" : + "" + ); + + $acceptRoutesButton = $tailscaleInfo->acceptsRoutes() ? "" : + ( + $tailscaleConfig->AllowRoutes ? "" : + "" + ); + + $sshButton = $tailscaleInfo->runsSSH() ? + "" : + ""; + + $autoUpdateButton = $tailscaleInfo->autoUpdateEnabled() ? + "" : + ""; + + $advertiseExitButton = $tailscaleInfo->usesExitNode() ? "" : + ( + $tailscaleInfo->advertisesExitNode() ? + "" : + "" + ); + + $exitLocalButton = $tailscaleInfo->exitNodeLocalAccess() ? + "" : + ""; + + $connectionRows = <<{$tr->tr("info.hostname")}{$tailscaleConInfo->HostName} + {$tr->tr("info.dns")}{$tailscaleConInfo->DNSName} + {$tr->tr("info.ip")}{$tailscaleConInfo->TailscaleIPs} + {$tr->tr("info.magicdns")}{$tailscaleConInfo->MagicDNSSuffix} + {$tr->tr("tailnet")}{$tailscaleInfo->getTailnetName()} + EOT; + + $exitDisabled = $tailscaleInfo->advertisesExitNode() ? "disabled" : ""; + $currentExit = $tailscaleInfo->getCurrentExitNode(); + + $exitSelect = ""; + + $relayPort = $tailscaleInfo->getRelayServerPort() ?: ""; + $relayPortWarning = $relayPort !== "" && ! $tailscaleInfo->isApprovedPeerRelay() ? $tr->tr("warnings.peer_relay_no_acl") : " "; + + $configRows = <<{$tr->tr("info.auto_update")}{$tailscaleConInfo->AutoUpdate}{$autoUpdateButton} + {$tr->tr("info.accept_routes")}{$tailscaleConInfo->AcceptRoutes}{$acceptRoutesButton} + {$tr->tr("info.accept_dns")}{$tailscaleConInfo->AcceptDNS}{$acceptDNSButton} + {$tr->tr("info.run_ssh")}{$tailscaleConInfo->RunSSH}{$sshButton} + {$tr->tr("info.advertise_exit_node")}{$tailscaleConInfo->AdvertiseExitNode}{$advertiseExitButton} + {$tr->tr("info.use_exit_node")} {$exitSelect} + {$tr->tr("info.exit_node_local")}{$tailscaleConInfo->ExitNodeLocal}{$exitLocalButton} + + {$tr->tr("info.peer_relay")} + {$relayPortWarning} + + + + + + + EOT; + + if (Utils::isFunnelAllowed() && $tailscaleConfig->AllowFunnel) { + // Create a list of ports similar to the one used by the exit node selection. + // Available ports can be obtained with $tailscaleInfo->getAllowedFunnelPorts + // Any port that is returned by Utils::get_assigned_ports should not be selectable + $funnelPorts = $tailscaleInfo->getAllowedFunnelPorts(); + $assignedPorts = $utils->get_assigned_ports(); + + $utils->logmsg("Funnel ports: " . implode(", ", $funnelPorts)); + $utils->logmsg("Assigned ports: " . implode(", ", $assignedPorts)); + + $funnelSelect = ""; + $configRows .= <<{$tr->tr("info.funnel_port")} {$funnelSelect} + EOT; + } + + $routesRows = ""; + + foreach ($tailscaleInfo->getAdvertisedRoutes() as $route) { + $approved = $tailscaleInfo->isApprovedRoute($route) ? "" : $tr->tr("info.unapproved"); + $routesRows .= "{$route}{$approved}"; + } + + $routes = << + + + {$tr->tr('info.routes')} +   +   + + + + {$routesRows} + + + + EOT; + + $config = << + + + {$tr->tr('configuration')} +   +   + + + + {$configRows} + + + EOT; + } + + $connection = << + + + {$tr->tr('connection')} +   +   + + + + {$connectionRows} + + + EOT; + + $rtn = array(); + $rtn['config'] = $config; + $rtn['routes'] = $routes; + $rtn['connection'] = $connection; + + echo json_encode($rtn); + break; + case 'set-feature': + $features = [ + 'dns' => 'CorpDNS', + 'routes' => 'RouteAll', + 'ssh' => 'RunSSH', + 'exit-allow-local' => 'ExitNodeAllowLANAccess' + ]; + + if ( ! (isset($features[$_POST['feature']]))) { + throw new \Exception("Invalid feature: {$_POST['feature']}"); + } + + if ( ! isset($_POST['enable'])) { + throw new \Exception("Missing enable parameter"); + } + + $enable = filter_var($_POST['enable'], FILTER_VALIDATE_BOOLEAN); + $utils->logmsg("Setting feature: {$features[$_POST['feature']]} to " . ($enable ? "true" : "false")); + + $localAPI->patchPref($features[$_POST['feature']], $enable); + break; + case 'set-advertise-exit-node': + if ( ! isset($_POST['enable'])) { + throw new \Exception("Missing enable parameter"); + } + + $enable = filter_var($_POST['enable'], FILTER_VALIDATE_BOOLEAN); + $utils->logmsg("Setting advertise exit node to " . ($enable ? "true" : "false")); + + $prefs = $localAPI->getPrefs(); + $routes = $prefs->AdvertiseRoutes ?? array(); + $exitRoutes = Utils::getExitRoutes(); + + if ($enable) { + $routes = array_unique(array_merge($routes, $exitRoutes)); + } else { + $routes = array_diff($routes, $exitRoutes); + } + + $localAPI->patchPref("AdvertiseRoutes", array_values($routes)); + break; + case 'up': + $utils->logmsg("Getting Auth URL"); + $authURL = $tailscaleInfo->getAuthURL(); + if ($authURL == "") { + $localAPI->postLoginInteractive(); + $retries = 0; + while ($retries < 60) { + $tailscaleInfo = new Info($tr); + $authURL = $tailscaleInfo->getAuthURL(); + if ($authURL != "") { + break; + } + usleep(500000); + $retries++; + } + } + echo $authURL; + break; + case 'remove-route': + if ( ! isset($_POST['route'])) { + throw new \Exception("Missing route parameter"); + } + + $utils->logmsg("Removing route: {$_POST['route']}"); + + $advertisedRoutes = $tailscaleInfo->getAdvertisedRoutes(); + $advertisedRoutes = array_diff($advertisedRoutes, [$_POST['route']]); + + $localAPI->patchPref("AdvertiseRoutes", array_values($advertisedRoutes)); + break; + case 'add-route': + if ( ! isset($_POST['route'])) { + throw new \Exception("Missing route parameter"); + } + + if ( ! Utils::validateCidr($_POST['route'])) { + throw new \Exception("Invalid route: {$_POST['route']}"); + } + + $utils->logmsg("Adding route: {$_POST['route']}"); + + $advertisedRoutes = $tailscaleInfo->getAdvertisedRoutes(); + $advertisedRoutes[] = $_POST['route']; + + $localAPI->patchPref("AdvertiseRoutes", array_values($advertisedRoutes)); + break; + case 'expire-key': + if ($tailscaleInfo->connectedViaTS()) { + throw new \Exception("Cannot expire key while connected via Tailscale"); + } + $utils->logmsg("Expiring node key"); + $localAPI->expireKey(); + break; + case 'set-auto-update': + if ( ! isset($_POST['enable'])) { + throw new \Exception("Missing enable parameter"); + } + + $enable = filter_var($_POST['enable'], FILTER_VALIDATE_BOOLEAN); + $utils->logmsg("Setting auto update to " . ($enable ? "true" : "false")); + + $localAPI->setAutoUpdate($enable); + break; + case 'exit-node': + if ( ! isset($_POST['node'])) { + throw new \Exception("Missing node parameter"); + } + + $exitNodes = $tailscaleInfo->getExitNodes(); + if (( ! isset($exitNodes[$_POST['node']])) && ($_POST['node'] != '')) { + throw new \Exception("Invalid node parameter"); + } + + $utils->logmsg("Setting exit node: {$_POST['node']}"); + + $localAPI->patchPref("ExitNodeID", $_POST['node']); + break; + case 'funnel-port': + if ( ! isset($_POST['port'])) { + throw new \Exception("Missing port parameter"); + } + + $identCfg = parse_ini_file("/boot/config/ident.cfg", false, INI_SCANNER_RAW) ?: array(); + if ( ! isset($identCfg['PORT'])) { + throw new \Exception("Ident configuration does not contain PORT"); + } + + $hostname = trim($tailscaleInfo->getDNSName(), "."); + + $serveConfig = $localAPI->getServeConfig(); + $currentFunnelPort = $serveConfig->getFunnelPort($hostname); + + if ($currentFunnelPort == $_POST['port']) { + break; + } elseif ($currentFunnelPort != '') { + $serveConfig->removeFunnel($hostname, $currentFunnelPort); + } + + $savePort = ""; + + if ($_POST['port'] != '') { + $serveConfig->configureFunnel( + $hostname, + $_POST['port'], + "http://localhost:" . $identCfg['PORT'] + ); + $savePort = $identCfg['PORT']; + } + + $utils->logmsg("Object: " . json_encode($serveConfig->getConfig(), JSON_UNESCAPED_SLASHES)); + $localAPI->setServeConfig($serveConfig); + + $serveConfig->saveWebguiPort($savePort); + + break; + case 'set-relay-port': + if ( ! isset($_POST['port'])) { + throw new \Exception("Missing port parameter"); + } + + $port = null; + + if ($_POST['port'] === '') { + $port = null; + } elseif (ctype_digit($_POST['port'])) { + $port = intval($_POST['port']); + if (($port < 0) || ($port > 65535)) { + throw new \Exception("Port out of range: {$_POST['port']}"); + } + } else { + throw new \Exception("Invalid port: {$_POST['port']}"); + } + + $utils->logmsg("Setting relay server port to: {$port}"); + + $localAPI->patchPref("RelayServerPort", $port); + break; + } +} catch (\Throwable $e) { + file_put_contents("/var/log/tailscale-error.log", print_r($e, true) . PHP_EOL, FILE_APPEND); + echo "{}"; +} diff --git a/src/usr/local/emhttp/plugins/tailscale/include/data/Lock.php b/src/usr/local/emhttp/plugins/tailscale/include/data/Lock.php new file mode 100644 index 0000000..6bcb3c3 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/data/Lock.php @@ -0,0 +1,78 @@ +. +*/ + +use EDACerton\PluginUtils\Translator; + +try { + require_once dirname(dirname(__FILE__)) . "/common.php"; + + if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); + } + + $tr = $tr ?? new Translator(PLUGIN_ROOT); + + $tailscaleConfig = $tailscaleConfig ?? new Config(); + + if ( ! $tailscaleConfig->Enable) { + echo("{}"); + return; + } + + switch ($_POST['action']) { + case 'get': + $tailscaleInfo = $tailscaleInfo ?? new Info($tr); + $rows = ""; + + $mullvad = filter_var($_POST['mullvad'] ?? false, FILTER_VALIDATE_BOOLEAN); + + foreach ($tailscaleInfo->getTailscaleLockPending() as $lockHost => $lockKey) { + if ( ! $mullvad && str_contains($lockHost, 'mullvad.ts.net')) { + continue; + } + + $rows .= "{$lockHost}{$lockKey}"; + } + + $output = << + + +   + Name + Key + + + + {$rows} + + + EOT; + + $rtn = array(); + $rtn['html'] = $output; + echo json_encode($rtn); + break; + } +} catch (\Throwable $e) { + file_put_contents("/var/log/tailscale-error.log", print_r($e, true) . PHP_EOL, FILE_APPEND); + echo "{}"; +} diff --git a/src/usr/local/emhttp/plugins/tailscale/include/data/Status.php b/src/usr/local/emhttp/plugins/tailscale/include/data/Status.php new file mode 100644 index 0000000..cbf2415 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/data/Status.php @@ -0,0 +1,127 @@ +. +*/ + +use EDACerton\PluginUtils\Translator; + +try { + require_once dirname(dirname(__FILE__)) . "/common.php"; + + if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); + } + + $tr = $tr ?? new Translator(PLUGIN_ROOT); + $utils = $utils ?? new Utils(PLUGIN_NAME); + + $tailscaleConfig = $tailscaleConfig ?? new Config(); + + if ( ! $tailscaleConfig->Enable) { + echo("{}"); + return; + } + + switch ($_POST['action']) { + case 'get': + $tailscaleInfo = $tailscaleInfo ?? new Info($tr); + $rows = ""; + + $mullvad = filter_var($_POST['mullvad'] ?? false, FILTER_VALIDATE_BOOLEAN); + $shared = filter_var($_POST['shared'] ?? false, FILTER_VALIDATE_BOOLEAN); + + foreach ($tailscaleInfo->getPeerStatus() as $peer) { + if ($peer->Mullvad && ! $mullvad && ! $peer->Active) { + continue; + } + if ($peer->SharedUser && ! $shared && ! $peer->Active) { + continue; + } + + $user = $peer->SharedUser ? $tr->tr('status_page.shared') : $peer->Name; + $online = $peer->Online ? ($peer->Active ? $tr->tr('status_page.active') : $tr->tr('status_page.idle')) : $tr->tr('status_page.offline'); + $exitNode = $peer->ExitNodeActive ? $tr->tr('status_page.exit_active') : ($peer->ExitNodeAvailable ? ($peer->Mullvad ? "Mullvad" : $tr->tr('status_page.exit_available')) : ""); + $connection = $peer->Active ? ($peer->Relayed ? $tr->tr('status_page.relay') : $tr->tr('status_page.direct')) : ""; + $active = $peer->Active ? $peer->Address : ""; + $txBytes = $peer->Traffic ? $peer->TxBytes : ""; + $rxBytes = $peer->Traffic ? $peer->RxBytes : ""; + $pingHost = ($peer->SharedUser || $peer->Active || ! $peer->Online || $peer->Mullvad) ? "" : ""; + $ips = implode("
", $peer->IP); + + $rows .= << + {$user} + {$ips} + {$peer->LoginName} + {$online} + {$exitNode} + {$connection} + {$active} + {$txBytes} + {$rxBytes} + {$pingHost} + + EOT; + } + + $output = << + + + {$tr->tr('info.dns')} + {$tr->tr('info.ip')} + {$tr->tr('status_page.login_name')} + {$tr->tr('status')} + {$tr->tr('status_page.exit_node')} + {$tr->tr('status_page.connection_type')} + {$tr->tr('status_page.connection_addr')} + {$tr->tr('status_page.tx_bytes')} + {$tr->tr('status_page.rx_bytes')} + {$tr->tr('status_page.action')} + + + + {$rows} + + + EOT; + + $rtn = array(); + $rtn['html'] = $output; + echo json_encode($rtn); + break; + case 'ping': + $tailscaleInfo = $tailscaleInfo ?? new Info($tr); + $out = "Could not find host."; + + foreach ($tailscaleInfo->getPeerStatus() as $peer) { + if ($peer->Name == $_POST['host']) { + $peerIP = escapeshellarg($peer->IP[0]); + $out = implode("
", $utils->run_command("tailscale ping {$peerIP}")); + break; + } + } + + echo $out; + break; + } +} catch (\Throwable $e) { + file_put_contents("/var/log/tailscale-error.log", print_r($e, true) . PHP_EOL, FILE_APPEND); + echo "{}"; +} diff --git a/src/usr/local/emhttp/plugins/tailscale/include/page.php b/src/usr/local/emhttp/plugins/tailscale/include/page.php new file mode 100644 index 0000000..34477fc --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/page.php @@ -0,0 +1,58 @@ +. +*/ + +namespace Tailscale; + +/** +* @param array $params +*/ +function getPage(string $filename, bool $niceError = true, array $params = array()): string +{ + try { + require_once dirname(__FILE__) . "/common.php"; + return includePage(dirname(__FILE__) . "/Pages/{$filename}.php", $params); + } catch (\Throwable $e) { + if ($niceError) { + file_put_contents("/var/log/tailscale-error.log", print_r($e, true) . PHP_EOL, FILE_APPEND); + return includePage(dirname(__FILE__) . "/Pages/Error.php", array("e" => $e)); + } else { + throw $e; + } + } +} + +/** +* @param array $params +*/ +function includePage(string $filename, array $params = array()): string +{ + extract($params); + + if (is_file($filename)) { + ob_start(); + try { + include $filename; + return ob_get_clean() ?: ""; + } catch (\Throwable $e) { + ob_end_clean(); + throw $e; + } + } + return ""; +} diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/disabled.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/disabled.php new file mode 100644 index 0000000..3470a45 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/disabled.php @@ -0,0 +1,32 @@ +. +*/ + +namespace Tailscale; + +use EDACerton\PluginUtils\Translator; + +if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); +} + +$tr = $tr ?? new Translator(PLUGIN_ROOT); +?> +

tr("tailscale_lock"); ?>

+ +

tr("lock.disabled"); ?>

\ No newline at end of file diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/locked.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/locked.php new file mode 100644 index 0000000..61aeef8 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/locked.php @@ -0,0 +1,45 @@ +. +*/ + +namespace Tailscale; + +use EDACerton\PluginUtils\Translator; + +if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); +} + +$tr = $tr ?? new Translator(PLUGIN_ROOT); +?> +

tr("tailscale_lock"); ?>

+ +

+tr('lock.unsigned'); ?>. +

+ +

tr('lock.unsigned_instructions'); ?>

+ + + +
getTailscaleLockNodekey(); ?>
\ No newline at end of file diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signed.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signed.php new file mode 100644 index 0000000..3fc998b --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signed.php @@ -0,0 +1,47 @@ +. +*/ + +namespace Tailscale; + +use EDACerton\PluginUtils\Translator; + +if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); +} + +$tr = $tr ?? new Translator(PLUGIN_ROOT); +?> +

tr("tailscale_lock"); ?>

+ +

+ tr('lock.signed_node'); ?> +

+ +

+tr('lock.make_signing'); ?> +

+ + + +
getTailscaleLockPubkey(); ?>
diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signing.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signing.php new file mode 100644 index 0000000..f0e38c3 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signing.php @@ -0,0 +1,80 @@ +. +*/ + +namespace Tailscale; + +use EDACerton\PluginUtils\Translator; + +if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); +} + +$tr = $tr ?? new Translator(PLUGIN_ROOT); +?> +

tr("tailscale_lock"); ?>

+

tr("lock.sign"); ?>

+

+ tr("lock.signing_node"); ?> +

+

+tr("lock.signing_instructions"); ?> +

+ + + + +
+ +
 

+ + +Display unsigned Mullvad nodes +
diff --git a/src/usr/local/emhttp/plugins/tailscale/lib/ipaddr.min.js b/src/usr/local/emhttp/plugins/tailscale/lib/ipaddr.min.js new file mode 100644 index 0000000..bd03676 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/lib/ipaddr.min.js @@ -0,0 +1 @@ +!function(t){!function(t){"use strict";const r="(0?\\d+|0x[a-f0-9]+)",e={fourOctet:new RegExp(`^${r}\\.${r}\\.${r}\\.${r}$`,"i"),threeOctet:new RegExp(`^${r}\\.${r}\\.${r}$`,"i"),twoOctet:new RegExp(`^${r}\\.${r}$`,"i"),longValue:new RegExp(`^${r}$`,"i")},n=new RegExp("^0[0-7]+$","i"),i=new RegExp("^0x[a-f0-9]+$","i"),o="(?:[0-9a-f]+::?)+",s={zoneIndex:new RegExp("%[0-9a-z]{1,}","i"),native:new RegExp(`^(::)?(${o})?([0-9a-f]+)?(::)?(%[0-9a-z]{1,})?$`,"i"),deprecatedTransitional:new RegExp(`^(?:::)(${r}\\.${r}\\.${r}\\.${r}(%[0-9a-z]{1,})?)$`,"i"),transitional:new RegExp(`^((?:${o})|(?:::)(?:${o})?)${r}\\.${r}\\.${r}\\.${r}(%[0-9a-z]{1,})?$`,"i")};function a(t,r){if(t.indexOf("::")!==t.lastIndexOf("::"))return null;let e,n,i=0,o=-1,a=(t.match(s.zoneIndex)||[])[0];for(a&&(a=a.substring(1),t=t.replace(/%.+$/,""));(o=t.indexOf(":",o+1))>=0;)i++;if("::"===t.substr(0,2)&&i--,"::"===t.substr(-2,2)&&i--,i>r)return null;for(n=r-i,e=":";n--;)e+="0:";return":"===(t=t.replace("::",e))[0]&&(t=t.slice(1)),":"===t[t.length-1]&&(t=t.slice(0,-1)),{parts:r=function(){const r=t.split(":"),e=[];for(let t=0;t0;){if((i=e-n)<0&&(i=0),t[o]>>i!=r[o]>>i)return!1;n-=e,o+=1}return!0}function u(t){if(i.test(t))return parseInt(t,16);if("0"===t[0]&&!isNaN(parseInt(t[1],10))){if(n.test(t))return parseInt(t,8);throw new Error(`ipaddr: cannot parse ${t} as octal`)}return parseInt(t,10)}function d(t,r){for(;t.length=0;n-=1){if(!((i=this.octets[n])in e))return null;if(o=e[i],r&&0!==o)return null;8!==o&&(r=!0),t+=o}return 32-t},t.prototype.range=function(){return h.subnetMatch(this,this.SpecialRanges)},t.prototype.toByteArray=function(){return this.octets.slice(0)},t.prototype.toIPv4MappedAddress=function(){return h.IPv6.parse(`::ffff:${this.toString()}`)},t.prototype.toNormalizedString=function(){return this.toString()},t.prototype.toString=function(){return this.octets.join(".")},t}(),h.IPv4.broadcastAddressFromCIDR=function(t){try{const r=this.parseCIDR(t),e=r[0].toByteArray(),n=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[];let o=0;for(;o<4;)i.push(parseInt(e[o],10)|255^parseInt(n[o],10)),o++;return new this(i)}catch(t){throw new Error("ipaddr: the address does not have IPv4 CIDR format")}},h.IPv4.isIPv4=function(t){return null!==this.parser(t)},h.IPv4.isValid=function(t){try{return new this(this.parser(t)),!0}catch(t){return!1}},h.IPv4.isValidCIDR=function(t){try{return this.parseCIDR(t),!0}catch(t){return!1}},h.IPv4.isValidFourPartDecimal=function(t){return!(!h.IPv4.isValid(t)||!t.match(/^(0|[1-9]\d*)(\.(0|[1-9]\d*)){3}$/))},h.IPv4.networkAddressFromCIDR=function(t){let r,e,n,i,o;try{for(n=(r=this.parseCIDR(t))[0].toByteArray(),o=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[],e=0;e<4;)i.push(parseInt(n[e],10)&parseInt(o[e],10)),e++;return new this(i)}catch(t){throw new Error("ipaddr: the address does not have IPv4 CIDR format")}},h.IPv4.parse=function(t){const r=this.parser(t);if(null===r)throw new Error("ipaddr: string is not formatted like an IPv4 Address");return new this(r)},h.IPv4.parseCIDR=function(t){let r;if(r=t.match(/^(.+)\/(\d+)$/)){const t=parseInt(r[2]);if(t>=0&&t<=32){const e=[this.parse(r[1]),t];return Object.defineProperty(e,"toString",{value:function(){return this.join("/")}}),e}}throw new Error("ipaddr: string is not formatted like an IPv4 CIDR range")},h.IPv4.parser=function(t){let r,n,i;if(r=t.match(e.fourOctet))return function(){const t=r.slice(1,6),e=[];for(let r=0;r4294967295||i<0)throw new Error("ipaddr: address outside defined range");return function(){const t=[];let r;for(r=0;r<=24;r+=8)t.push(i>>r&255);return t}().reverse()}return(r=t.match(e.twoOctet))?function(){const t=r.slice(1,4),e=[];if((i=u(t[1]))>16777215||i<0)throw new Error("ipaddr: address outside defined range");return e.push(u(t[0])),e.push(i>>16&255),e.push(i>>8&255),e.push(255&i),e}():(r=t.match(e.threeOctet))?function(){const t=r.slice(1,5),e=[];if((i=u(t[2]))>65535||i<0)throw new Error("ipaddr: address outside defined range");return e.push(u(t[0])),e.push(u(t[1])),e.push(i>>8&255),e.push(255&i),e}():null},h.IPv4.subnetMaskFromPrefixLength=function(t){if((t=parseInt(t))<0||t>32)throw new Error("ipaddr: invalid IPv4 prefix length");const r=[0,0,0,0];let e=0;const n=Math.floor(t/8);for(;e=0;o-=1){if(!((n=this.parts[o])in e))return null;if(i=e[n],r&&0!==i)return null;16!==i&&(r=!0),t+=i}return 128-t},t.prototype.range=function(){return h.subnetMatch(this,this.SpecialRanges)},t.prototype.toByteArray=function(){let t;const r=[],e=this.parts;for(let n=0;n>8),r.push(255&t);return r},t.prototype.toFixedLengthString=function(){const t=function(){const t=[];for(let r=0;r>8,255&r,e>>8,255&e])},t.prototype.toNormalizedString=function(){const t=function(){const t=[];for(let r=0;ri&&(n=e.index,i=e[0].length);return i<0?r:`${r.substring(0,n)}::${r.substring(n+i)}`},t.prototype.toString=function(){return this.toRFC5952String()},t}(),h.IPv6.broadcastAddressFromCIDR=function(t){try{const r=this.parseCIDR(t),e=r[0].toByteArray(),n=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[];let o=0;for(;o<16;)i.push(parseInt(e[o],10)|255^parseInt(n[o],10)),o++;return new this(i)}catch(t){throw new Error(`ipaddr: the address does not have IPv6 CIDR format (${t})`)}},h.IPv6.isIPv6=function(t){return null!==this.parser(t)},h.IPv6.isValid=function(t){if("string"==typeof t&&-1===t.indexOf(":"))return!1;try{const r=this.parser(t);return new this(r.parts,r.zoneId),!0}catch(t){return!1}},h.IPv6.isValidCIDR=function(t){if("string"==typeof t&&-1===t.indexOf(":"))return!1;try{return this.parseCIDR(t),!0}catch(t){return!1}},h.IPv6.networkAddressFromCIDR=function(t){let r,e,n,i,o;try{for(n=(r=this.parseCIDR(t))[0].toByteArray(),o=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[],e=0;e<16;)i.push(parseInt(n[e],10)&parseInt(o[e],10)),e++;return new this(i)}catch(t){throw new Error(`ipaddr: the address does not have IPv6 CIDR format (${t})`)}},h.IPv6.parse=function(t){const r=this.parser(t);if(null===r.parts)throw new Error("ipaddr: string is not formatted like an IPv6 Address");return new this(r.parts,r.zoneId)},h.IPv6.parseCIDR=function(t){let r,e,n;if((e=t.match(/^(.+)\/(\d+)$/))&&(r=parseInt(e[2]))>=0&&r<=128)return n=[this.parse(e[1]),r],Object.defineProperty(n,"toString",{value:function(){return this.join("/")}}),n;throw new Error("ipaddr: string is not formatted like an IPv6 CIDR range")},h.IPv6.parser=function(t){let r,e,n,i,o,p;if(n=t.match(s.deprecatedTransitional))return this.parser(`::ffff:${n[1]}`);if(s.native.test(t))return a(t,8);if((n=t.match(s.transitional))&&(p=n[6]||"",r=n[1],n[1].endsWith("::")||(r=r.slice(0,-1)),(r=a(r+p,6)).parts)){for(o=[parseInt(n[2]),parseInt(n[3]),parseInt(n[4]),parseInt(n[5])],e=0;e128)throw new Error("ipaddr: invalid IPv6 prefix length");const r=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];let e=0;const n=Math.floor(t/8);for(;e.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/src/usr/local/emhttp/plugins/tailscale/lib/select2/select2.min.js b/src/usr/local/emhttp/plugins/tailscale/lib/select2/select2.min.js new file mode 100644 index 0000000..e421426 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/lib/select2/select2.min.js @@ -0,0 +1,2 @@ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ +!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(u){var e=function(){if(u&&u.fn&&u.fn.select2&&u.fn.select2.amd)var e=u.fn.select2.amd;var t,n,r,h,o,s,f,g,m,v,y,_,i,a,b;function w(e,t){return i.call(e,t)}function l(e,t){var n,r,i,o,s,a,l,c,u,d,p,h=t&&t.split("/"),f=y.map,g=f&&f["*"]||{};if(e){for(s=(e=e.split("/")).length-1,y.nodeIdCompat&&b.test(e[s])&&(e[s]=e[s].replace(b,"")),"."===e[0].charAt(0)&&h&&(e=h.slice(0,h.length-1).concat(e)),u=0;u":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},i.appendMany=function(e,t){if("1.7"===o.fn.jquery.substr(0,3)){var n=o();o.map(t,function(e){n=n.add(e)}),t=n}e.append(t)},i.__cache={};var n=0;return i.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null==t&&(e.id?(t=e.id,e.setAttribute("data-select2-id",t)):(e.setAttribute("data-select2-id",++n),t=n.toString())),t},i.StoreData=function(e,t,n){var r=i.GetUniqueElementId(e);i.__cache[r]||(i.__cache[r]={}),i.__cache[r][t]=n},i.GetData=function(e,t){var n=i.GetUniqueElementId(e);return t?i.__cache[n]&&null!=i.__cache[n][t]?i.__cache[n][t]:o(e).data(t):i.__cache[n]},i.RemoveData=function(e){var t=i.GetUniqueElementId(e);null!=i.__cache[t]&&delete i.__cache[t],e.removeAttribute("data-select2-id")},i}),e.define("select2/results",["jquery","./utils"],function(h,f){function r(e,t,n){this.$element=e,this.data=n,this.options=t,r.__super__.constructor.call(this)}return f.Extend(r,f.Observable),r.prototype.render=function(){var e=h('
    ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},r.prototype.clear=function(){this.$results.empty()},r.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=h(''),r=this.options.get("translations").get(e.message);n.append(t(r(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},r.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},r.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested"});p.append(l),s.append(a),s.append(p)}else this.template(e,t);return f.StoreData(t,"data",e),t},r.prototype.bind=function(t,e){var l=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){l.clear(),l.append(e.data),t.isOpen()&&(l.setClasses(),l.highlightFirstItem())}),t.on("results:append",function(e){l.append(e.data),t.isOpen()&&l.setClasses()}),t.on("query",function(e){l.hideMessages(),l.showLoading(e)}),t.on("select",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("open",function(){l.$results.attr("aria-expanded","true"),l.$results.attr("aria-hidden","false"),l.setClasses(),l.ensureHighlightVisible()}),t.on("close",function(){l.$results.attr("aria-expanded","false"),l.$results.attr("aria-hidden","true"),l.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=l.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e=l.getHighlightedResults();if(0!==e.length){var t=f.GetData(e[0],"data");"true"==e.attr("aria-selected")?l.trigger("close",{}):l.trigger("select",{data:t})}}),t.on("results:previous",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e);if(!(n<=0)){var r=n-1;0===e.length&&(r=0);var i=t.eq(r);i.trigger("mouseenter");var o=l.$results.offset().top,s=i.offset().top,a=l.$results.scrollTop()+(s-o);0===r?l.$results.scrollTop(0):s-o<0&&l.$results.scrollTop(a)}}),t.on("results:next",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e)+1;if(!(n>=t.length)){var r=t.eq(n);r.trigger("mouseenter");var i=l.$results.offset().top+l.$results.outerHeight(!1),o=r.offset().top+r.outerHeight(!1),s=l.$results.scrollTop()+o-i;0===n?l.$results.scrollTop(0):ithis.$results.outerHeight()||o<0)&&this.$results.scrollTop(i)}},r.prototype.template=function(e,t){var n=this.options.get("templateResult"),r=this.options.get("escapeMarkup"),i=n(e,t);null==i?t.style.display="none":"string"==typeof i?t.innerHTML=r(i):h(t).append(i)},r}),e.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),e.define("select2/selection/base",["jquery","../utils","../keys"],function(n,r,i){function o(e,t){this.$element=e,this.options=t,o.__super__.constructor.call(this)}return r.Extend(o,r.Observable),o.prototype.render=function(){var e=n('');return this._tabindex=0,null!=r.GetData(this.$element[0],"old-tabindex")?this._tabindex=r.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},o.prototype.bind=function(e,t){var n=this,r=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===i.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",r),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},o.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},o.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&r.GetData(this,"element").select2("close")})})},o.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},o.prototype.position=function(e,t){t.find(".selection").append(e)},o.prototype.destroy=function(){this._detachCloseHandler(this.container)},o.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},o.prototype.isEnabled=function(){return!this.isDisabled()},o.prototype.isDisabled=function(){return this.options.get("disabled")},o}),e.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,r){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e.addClass("select2-selection--single"),e.html(''),e},i.prototype.bind=function(t,e){var n=this;i.__super__.bind.apply(this,arguments);var r=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",r).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",r),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},i.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},i.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},i.prototype.selectionContainer=function(){return e("")},i.prototype.update=function(e){if(0!==e.length){var t=e[0],n=this.$selection.find(".select2-selection__rendered"),r=this.display(t,n);n.empty().append(r);var i=t.title||t.text;i?n.attr("title",i):n.removeAttr("title")}else this.clear()},i}),e.define("select2/selection/multiple",["jquery","./base","../utils"],function(i,e,l){function n(e,t){n.__super__.constructor.apply(this,arguments)}return l.Extend(n,e),n.prototype.render=function(){var e=n.__super__.render.call(this);return e.addClass("select2-selection--multiple"),e.html('
      '),e},n.prototype.bind=function(e,t){var r=this;n.__super__.bind.apply(this,arguments),this.$selection.on("click",function(e){r.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){if(!r.isDisabled()){var t=i(this).parent(),n=l.GetData(t[0],"data");r.trigger("unselect",{originalEvent:e,data:n})}})},n.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},n.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},n.prototype.selectionContainer=function(){return i('
    • ×
    • ')},n.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=0;n×');a.StoreData(r[0],"data",t),this.$selection.find(".select2-selection__rendered").prepend(r)}},e}),e.define("select2/selection/search",["jquery","../utils","../keys"],function(r,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=r('');this.$searchContainer=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),t.on("open",function(){r.$search.attr("aria-controls",i),r.$search.trigger("focus")}),t.on("close",function(){r.$search.val(""),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.trigger("focus")}),t.on("enable",function(){r.$search.prop("disabled",!1),r._transferTabIndex()}),t.on("disable",function(){r.$search.prop("disabled",!0)}),t.on("focus",function(e){r.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){r.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){r._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===r.$search.val()){var t=r.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("select",function(){r._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var r=this;this._checkIfMaximumSelected(function(){e.call(r,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var r=this;this.current(function(e){var t=null!=e?e.length:0;0=r.maximumSelectionLength?r.trigger("results:message",{message:"maximumSelected",args:{maximum:r.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){r.handleSearch(e)}),t.on("open",function(){r.$search.attr("tabindex",0),r.$search.attr("aria-controls",i),r.$search.trigger("focus"),window.setTimeout(function(){r.$search.trigger("focus")},0)}),t.on("close",function(){r.$search.attr("tabindex",-1),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.val(""),r.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||r.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(r.showSearch(e)?r.$searchContainer.removeClass("select2-search--hide"):r.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,r){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,r)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),r=t.length-1;0<=r;r--){var i=t[r];this.placeholder.id===i.id&&n.splice(r,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,r){this.lastParams={},e.call(this,t,n,r),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("query",function(e){r.lastParams=e,r.loading=!0}),t.on("query:append",function(e){r.lastParams=e,r.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
    • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("open",function(){r._showDropdown(),r._attachPositioningHandler(t),r._bindContainerResultHandlers(t)}),t.on("close",function(){r._hideDropdown(),r._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,r="scroll.select2."+t.id,i="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(r,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(r+" "+i+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,r="resize.select2."+t.id,i="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+r+" "+i)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),r=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=i.top,o.bottom=i.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+s,d={left:i.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(r="below"),u||!c||t?!c&&u&&t&&(r="below"):r="above",("above"==r||t&&"below"!==r)&&(d.top=o.top-h.top-s),null!=r&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+r),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+r)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,r){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,r)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,r=0;r');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("jquery-mousewheel",["jquery"],function(e){return e}),e.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,o,t,s){if(null==i.fn.select2){var a=["open","close","destroy"];i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new o(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,r=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=s.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,r)}),-1/dev/null diff --git a/src/usr/local/emhttp/plugins/tailscale/settings.json b/src/usr/local/emhttp/plugins/tailscale/settings.json new file mode 100644 index 0000000..9ada3ee --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/settings.json @@ -0,0 +1,34 @@ +{ + "INCLUDE_INTERFACE": { + "default": "1", + "description": "Adds tailscale interface to Unraid services" + }, + "USAGE": { + "default": "1", + "description": "Allow collection of usage data" + }, + "ACCEPT_DNS": { + "default": "0", + "description": "Use MagicDNS" + }, + "ACCEPT_ROUTES": { + "default": "0", + "description": "Use routes from the tailnet" + }, + "SYSCTL_IP_FORWARD": { + "default": "1", + "description": "Enable IP forwarding in sysctl" + }, + "ENABLE_TAILSCALE": { + "default": "1", + "description": "Tailscale enabled" + }, + "WG_PORT": { + "default": "0", + "description": "Port for Wireguard connections" + }, + "TAILDROP_DIR": { + "default": "", + "description": "Destination for Taildrop files" + } +} diff --git a/src/usr/local/emhttp/plugins/tailscale/style.css b/src/usr/local/emhttp/plugins/tailscale/style.css new file mode 100644 index 0000000..d4bdbb8 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/style.css @@ -0,0 +1,19 @@ +.tablesorter .filtered { + display: none; +} +.tablesorter-filter.disabled { + display: none; +} +.fileTree { + width: 300px; + max-height: 150px; + overflow-y: scroll; + overflow-x: hidden; + position: absolute; + z-index: 100; + display: none +} + +li.select2-results__option { + color: black; +} \ No newline at end of file diff --git a/src/usr/local/emhttp/plugins/tailscale/tailscale-watcher.php b/src/usr/local/emhttp/plugins/tailscale/tailscale-watcher.php new file mode 100755 index 0000000..f89a647 --- /dev/null +++ b/src/usr/local/emhttp/plugins/tailscale/tailscale-watcher.php @@ -0,0 +1,10 @@ +#!/usr/bin/php -q +run(); diff --git a/src/usr/local/emhttp/plugins/tailscale/tailscale.png b/src/usr/local/emhttp/plugins/tailscale/tailscale.png new file mode 100644 index 0000000..8408ec6 Binary files /dev/null and b/src/usr/local/emhttp/plugins/tailscale/tailscale.png differ diff --git a/src/usr/local/etc/rc.d/rc.tailscale b/src/usr/local/etc/rc.d/rc.tailscale new file mode 100755 index 0000000..723eb1b --- /dev/null +++ b/src/usr/local/etc/rc.d/rc.tailscale @@ -0,0 +1,53 @@ +#!/bin/sh +# /etc/rc.d/rc.tailscaled - start/stop the tailscaled daemon + +. /usr/local/php/unraid-tailscale-utils/log.sh + +start_tailscaled() { + if ! /usr/bin/pgrep --ns $$ --euid root -f "^/usr/local/sbin/tailscaled" 1> /dev/null 2> /dev/null ; then + + /usr/local/php/unraid-tailscale-utils/pre-startup.php + if [ $? -ne 0 ]; then + log "Tailscale is disabled in settings, not starting tailscaled." + return + fi + + if [ -f /usr/local/emhttp/plugins/tailscale/custom-params.sh ]; then + . /usr/local/emhttp/plugins/tailscale/custom-params.sh + else + TAILSCALE_CUSTOM_PARAMS="" + fi + + TAILSCALE_START_CMD="/usr/local/sbin/tailscaled -statedir /boot/config/plugins/tailscale/state -tun tailscale1 $TAILSCALE_CUSTOM_PARAMS" + log "Starting tailscaled: $TAILSCALE_START_CMD" + mkdir -p /boot/config/plugins/tailscale/state + $TAILSCALE_START_CMD 2>&1 | grep -vF "monitor: [unexpected]" >> /var/log/tailscale.log & + nohup /usr/local/emhttp/plugins/tailscale/tailscale-watcher.php 1>/dev/null 2>&1 & + fi +} + +stop_tailscaled() { + log "Stopping tailscaled." + killall --ns $$ --wait tailscale-watcher.php 2> /dev/null + killall --ns $$ --wait tailscaled 2> /dev/null +} + +restart_tailscaled() { + stop_tailscaled + sleep 1 + start_tailscaled +} + +case "$1" in +'start') + start_tailscaled + ;; +'stop') + stop_tailscaled + ;; +'restart') + restart_tailscaled + ;; +*) + echo "usage $0 start|stop|restart" +esac diff --git a/src/usr/local/php/unraid-tailscale-utils/composer.json b/src/usr/local/php/unraid-tailscale-utils/composer.json new file mode 100644 index 0000000..3fba013 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/composer.json @@ -0,0 +1,35 @@ +{ + "name": "dkaser/unraid-tailscale-utils", + "description": "Tailscale configuration", + "type": "library", + "license": "GPL-3.0-or-later", + "autoload": { + "psr-4": { + "Tailscale\\": "unraid-tailscale-utils/" + } + }, + "authors": [ + { + "name": "Derek Kaser" + } + ], + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.64", + "phpstan/phpstan": "^2.1" + }, + "config": { + "sort-packages": true, + "bin-dir": "../../../../../vendor/bin/" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/dkaser/unraid-utils.git" + } + ], + "require": { + "edacerton/plugin-utils": "^1.0", + "rlanvin/php-ip": "^3.0", + "php": ">=8.3" + } +} \ No newline at end of file diff --git a/src/usr/local/php/unraid-tailscale-utils/composer.lock b/src/usr/local/php/unraid-tailscale-utils/composer.lock new file mode 100644 index 0000000..e56d24b --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/composer.lock @@ -0,0 +1,2828 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "5e629f49773cc054ee1b10817d8c5bb2", + "packages": [ + { + "name": "edacerton/plugin-utils", + "version": "1.6.0-stable", + "source": { + "type": "git", + "url": "https://github.com/dkaser/unraid-utils.git", + "reference": "b5c574fbbae82f4fc80818e2e446db2993035406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dkaser/unraid-utils/zipball/b5c574fbbae82f4fc80818e2e446db2993035406", + "reference": "b5c574fbbae82f4fc80818e2e446db2993035406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.64", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "EDACerton\\PluginUtils\\": "src/" + } + }, + "license": [ + "GPL-3.0-or-later" + ], + "authors": [ + { + "name": "Derek Kaser" + } + ], + "description": "Utility classes", + "support": { + "source": "https://github.com/dkaser/unraid-utils/tree/1.6.0-stable", + "issues": "https://github.com/dkaser/unraid-utils/issues" + }, + "time": "2026-01-04T15:19:06+00:00" + }, + { + "name": "rlanvin/php-ip", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/rlanvin/php-ip.git", + "reference": "7811f12256a5a610ddcb31ed9840179f4dd3784d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rlanvin/php-ip/zipball/7811f12256a5a610ddcb31ed9840179f4dd3784d", + "reference": "7811f12256a5a610ddcb31ed9840179f4dd3784d", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^6.5|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpIP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "IPv4/IPv6 manipulation library for PHP", + "homepage": "https://github.com/rlanvin/php-ip", + "keywords": [ + "IP", + "ipv4", + "ipv6" + ], + "support": { + "issues": "https://github.com/rlanvin/php-ip/issues", + "source": "https://github.com/rlanvin/php-ip/tree/v3.0.0" + }, + "time": "2022-03-02T08:51:37+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.92.5", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58", + "reference": "260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7", + "infection/infection": "^0.31", + "justinrainbow/json-schema": "^6.6", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", + "phpunit/phpunit": "^9.6.31 || ^10.5.60 || ^11.5.46", + "symfony/polyfill-php85": "^1.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.26 || ^7.4.0 || ^8.0", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.92.5" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-01-08T21:57:37+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.33", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", + "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-12-05T10:24:31+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-23T14:50:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-28T09:38:46+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-23T14:50:43+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b38026df55197f9e39a44f3215788edf83187b80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:39:26+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "608476f4604102976d687c483ac63a79ba18cc97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:07:59+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "8a24af0a2e8a872fb745047180649b8418303084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", + "reference": "8a24af0a2e8a872fb745047180649b8418303084", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-04T07:05:15+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/src/usr/local/php/unraid-tailscale-utils/daily.php b/src/usr/local/php/unraid-tailscale-utils/daily.php new file mode 100755 index 0000000..14a95d3 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/daily.php @@ -0,0 +1,34 @@ +#!/usr/bin/php -q +. +*/ + +namespace Tailscale; + +$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp'; + +require_once "{$docroot}/plugins/tailscale/include/common.php"; +if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); +} +$utils = new Utils(PLUGIN_NAME); + +$tailscaleConfig = $tailscaleConfig ?? new Config(); + +$utils->run_task('Tailscale\System::notifyOnKeyExpiration'); +$utils->run_task('Tailscale\System::refreshWebGuiCert'); diff --git a/src/usr/local/php/unraid-tailscale-utils/daily.sh b/src/usr/local/php/unraid-tailscale-utils/daily.sh new file mode 100755 index 0000000..fa9ae97 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/daily.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +/usr/local/php/unraid-tailscale-utils/daily.php 1>/dev/null 2>&1 diff --git a/src/usr/local/php/unraid-tailscale-utils/log.sh b/src/usr/local/php/unraid-tailscale-utils/log.sh new file mode 100644 index 0000000..da3609d --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/log.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +log() { + LOG_TIME=`date '+%Y/%m/%d %H:%M:%S'` + CALLER=`basename "$0"` + echo "$LOG_TIME $CALLER: $1" >> /var/log/tailscale-utils.log +} \ No newline at end of file diff --git a/src/usr/local/php/unraid-tailscale-utils/pre-startup.php b/src/usr/local/php/unraid-tailscale-utils/pre-startup.php new file mode 100755 index 0000000..8073ff4 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/pre-startup.php @@ -0,0 +1,43 @@ +#!/usr/bin/php -q +. +*/ + +namespace Tailscale; + +$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp'; +require_once "{$docroot}/plugins/tailscale/include/common.php"; + +if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); +} +$utils = new Utils(PLUGIN_NAME); + +$tailscaleConfig = $tailscaleConfig ?? new Config(); + +$utils->run_task('Tailscale\System::createTailscaledParamsFile', array($tailscaleConfig)); +$utils->run_task('Tailscale\System::applyGRO'); +$utils->run_task('Tailscale\System::setExtraInterface', array($tailscaleConfig)); +$utils->run_task('Tailscale\System::enableIPForwarding', array($tailscaleConfig)); +$utils->run_task('Tailscale\System::createTaildropLink', array($tailscaleConfig)); + +if ($tailscaleConfig->Enable) { + exit(0); +} else { + exit(1); +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Config.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Config.php new file mode 100644 index 0000000..0bf993b --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Config.php @@ -0,0 +1,66 @@ +. +*/ + +namespace Tailscale; + +class Config +{ + public bool $IncludeInterface; + public bool $Usage; + public bool $IPForward; + public bool $Enable; + public bool $SSH; + public bool $AllowDNS; + public bool $AllowRoutes; + public bool $AllowFunnel; + public bool $AddPeersToHosts; + public bool $NoLogsNoSupport; + public bool $UseTPM; + + public int $WgPort; + public string $TaildropDir; + + public function __construct() + { + $config_file = '/boot/config/plugins/tailscale/tailscale.cfg'; + + // Load configuration file + if (file_exists($config_file)) { + $saved_config = parse_ini_file($config_file) ?: array(); + } else { + $saved_config = array(); + } + + $this->IncludeInterface = boolval($saved_config["INCLUDE_INTERFACE"] ?? "1"); + $this->Usage = boolval($saved_config["USAGE"] ?? "1"); + $this->IPForward = boolval($saved_config["SYSCTL_IP_FORWARD"] ?? "1"); + $this->Enable = boolval($saved_config["ENABLE_TAILSCALE"] ?? "1"); + $this->SSH = boolval($saved_config["SSH"] ?? "0"); + $this->AllowDNS = boolval($saved_config["ACCEPT_DNS"] ?? "0"); + $this->AllowRoutes = boolval($saved_config["ACCEPT_ROUTES"] ?? "0"); + $this->AllowFunnel = boolval($saved_config["ALLOW_FUNNEL"] ?? "0"); + $this->AddPeersToHosts = boolval($saved_config["ADD_PEERS_TO_HOSTS"] ?? "0"); + $this->NoLogsNoSupport = boolval($saved_config["NO_LOGS_NO_SUPPORT"] ?? "0"); + $this->UseTPM = boolval($saved_config["USE_TPM"] ?? "0"); + + $this->WgPort = intval($saved_config["WG_PORT"] ?? "0"); + + $this->TaildropDir = $saved_config["TAILDROP_DIR"] ?? ""; + } +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ConnectionInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ConnectionInfo.php new file mode 100644 index 0000000..b34f79b --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ConnectionInfo.php @@ -0,0 +1,36 @@ +. +*/ + +namespace Tailscale; + +class ConnectionInfo +{ + public string $HostName = ""; + public string $DNSName = ""; + public string $TailscaleIPs = ""; + public string $MagicDNSSuffix = ""; + public string $AdvertisedRoutes = ""; + public string $AcceptRoutes = ""; + public string $AcceptDNS = ""; + public string $RunSSH = ""; + public string $ExitNodeLocal = ""; + public string $AdvertiseExitNode = ""; + public string $UseExitNode = ""; + public string $AutoUpdate = ""; +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/DashboardInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/DashboardInfo.php new file mode 100644 index 0000000..316f334 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/DashboardInfo.php @@ -0,0 +1,30 @@ +. +*/ + +namespace Tailscale; + +class DashboardInfo +{ + /** @var array $TailscaleIPs */ + public array $TailscaleIPs = array(); + + public string $HostName = ""; + public string $DNSName = ""; + public string $Online = ""; +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php new file mode 100644 index 0000000..6f283e0 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php @@ -0,0 +1,493 @@ +. +*/ + +namespace Tailscale; + +use EDACerton\PluginUtils\Translator; + +class Info +{ + private string $useNetbios; + private string $smbEnabled; + private ?Translator $tr; + private LocalAPI $localAPI; + private \stdClass $status; + private \stdClass $prefs; + private \stdClass $lock; + + public function __construct(?Translator $tr) + { + $share_config = parse_ini_file("/boot/config/share.cfg") ?: array(); + $ident_config = parse_ini_file("/boot/config/ident.cfg") ?: array(); + + $this->localAPI = new LocalAPI(); + + $this->tr = $tr; + $this->smbEnabled = $share_config['shareSMBEnabled'] ?? ""; + $this->useNetbios = $ident_config['USE_NETBIOS'] ?? ""; + $this->status = $this->localAPI->getStatus(); + $this->prefs = $this->localAPI->getPrefs(); + $this->lock = $this->localAPI->getTkaStatus(); + } + + public function getStatus(): \stdClass + { + return $this->status; + } + + public function getPrefs(): \stdClass + { + return $this->prefs; + } + + public function getLock(): \stdClass + { + return $this->lock; + } + + private function tr(string $message): string + { + if ($this->tr === null) { + return $message; + } + + return $this->tr->tr($message); + } + + public function getStatusInfo(): StatusInfo + { + $status = $this->status; + $prefs = $this->prefs; + $lock = $this->lock; + + $statusInfo = new StatusInfo(); + + $statusInfo->TsVersion = isset($status->Version) ? $status->Version : $this->tr("unknown"); + $statusInfo->KeyExpiration = isset($status->Self->KeyExpiry) ? $status->Self->KeyExpiry : $this->tr("disabled"); + $statusInfo->Online = isset($status->Self->Online) ? ($status->Self->Online ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown"); + $statusInfo->InNetMap = isset($status->Self->InNetworkMap) ? ($status->Self->InNetworkMap ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown"); + $statusInfo->Tags = isset($status->Self->Tags) ? implode("
      ", $status->Self->Tags) : ""; + $statusInfo->LoggedIn = isset($prefs->LoggedOut) ? ($prefs->LoggedOut ? $this->tr("no") : $this->tr("yes")) : $this->tr("unknown"); + $statusInfo->TsHealth = isset($status->Health) ? implode("
      ", $status->Health) : ""; + $statusInfo->LockEnabled = $this->getTailscaleLockEnabled() ? $this->tr("yes") : $this->tr("no"); + + if ($this->getTailscaleLockEnabled()) { + $lockInfo = new LockInfo(); + + $lockInfo->LockSigned = $this->getTailscaleLockSigned() ? $this->tr("yes") : $this->tr("no"); + $lockInfo->LockSigning = $this->getTailscaleLockSigning() ? $this->tr("yes") : $this->tr("no"); + $lockInfo->PubKey = $this->getTailscaleLockPubkey(); + $lockInfo->NodeKey = $this->getTailscaleLockNodekey(); + + $statusInfo->LockInfo = $lockInfo; + } + + return $statusInfo; + } + + public function getConnectionInfo(): ConnectionInfo + { + $status = $this->status; + $prefs = $this->prefs; + + $info = new ConnectionInfo(); + + $info->HostName = isset($status->Self->HostName) ? $status->Self->HostName : $this->tr("unknown"); + $info->DNSName = isset($status->Self->DNSName) ? $status->Self->DNSName : $this->tr("unknown"); + $info->TailscaleIPs = isset($status->TailscaleIPs) ? implode("
      ", $status->TailscaleIPs) : $this->tr("unknown"); + $info->MagicDNSSuffix = isset($status->MagicDNSSuffix) ? $status->MagicDNSSuffix : $this->tr("unknown"); + $info->AdvertisedRoutes = isset($prefs->AdvertiseRoutes) ? implode("
      ", $prefs->AdvertiseRoutes) : $this->tr("none"); + $info->AcceptRoutes = isset($prefs->RouteAll) ? ($prefs->RouteAll ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown"); + $info->AcceptDNS = isset($prefs->CorpDNS) ? ($prefs->CorpDNS ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown"); + $info->RunSSH = isset($prefs->RunSSH) ? ($prefs->RunSSH ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown"); + $info->ExitNodeLocal = isset($prefs->ExitNodeAllowLANAccess) ? ($prefs->ExitNodeAllowLANAccess ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown"); + $info->UseExitNode = $this->usesExitNode() ? $this->tr("yes") : $this->tr("no"); + $info->AutoUpdate = $this->autoUpdateEnabled() ? $this->tr("yes") : $this->tr("no"); + + if ($this->advertisesExitNode()) { + if ($this->status->Self->ExitNodeOption) { + $info->AdvertiseExitNode = $this->tr("yes"); + } else { + $info->AdvertiseExitNode = $this->tr("info.unapproved"); + } + } else { + $info->AdvertiseExitNode = $this->tr("no"); + } + + return $info; + } + + public function getDashboardInfo(): DashboardInfo + { + $status = $this->status; + + $info = new DashboardInfo(); + + $info->HostName = isset($status->Self->HostName) ? $status->Self->HostName : $this->tr("Unknown"); + $info->DNSName = isset($status->Self->DNSName) ? $status->Self->DNSName : $this->tr("Unknown"); + $info->TailscaleIPs = isset($status->TailscaleIPs) ? $status->TailscaleIPs : array(); + $info->Online = isset($status->Self->Online) ? ($status->Self->Online ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown"); + + return $info; + } + + public function getKeyExpirationWarning(): ?Warning + { + $status = $this->status; + + if (isset($status->Self->KeyExpiry)) { + $expiryTime = new \DateTime($status->Self->KeyExpiry); + $expiryTime->setTimezone(new \DateTimeZone(date_default_timezone_get())); + + $interval = $expiryTime->diff(new \DateTime('now')); + $expiryPrint = $expiryTime->format(\DateTimeInterface::RFC7231); + $intervalPrint = $interval->format('%a'); + + $warning = new Warning(sprintf($this->tr("warnings.key_expiration"), $intervalPrint, $expiryPrint)); + + switch (true) { + case $interval->days <= 7: + $warning->Priority = 'error'; + break; + case $interval->days <= 30: + $warning->Priority = 'warn'; + break; + default: + $warning->Priority = 'system'; + break; + } + + return $warning; + } + return null; + } + + public function getTailscaleLockEnabled(): bool + { + return $this->lock->Enabled ?? false; + } + + public function getTailscaleLockSigned(): bool + { + if ( ! $this->getTailscaleLockEnabled()) { + return false; + } + + return $this->lock->NodeKeySigned; + } + + public function getTailscaleLockNodekey(): string + { + if ( ! $this->getTailscaleLockEnabled()) { + return ""; + } + + return $this->lock->NodeKey; + } + + public function getTailscaleLockPubkey(): string + { + if ( ! $this->getTailscaleLockEnabled()) { + return ""; + } + + return $this->lock->PublicKey; + } + + public function getTailscaleLockSigning(): bool + { + if ( ! $this->getTailscaleLockSigned()) { + return false; + } + + $isTrusted = false; + $myKey = $this->getTailscaleLockPubkey(); + + foreach ($this->lock->TrustedKeys as $item) { + if ($item->Key == $myKey) { + $isTrusted = true; + } + } + + return $isTrusted; + } + + /** + * @return array + */ + public function getTailscaleLockPending(): array + { + if ( ! $this->getTailscaleLockSigning()) { + return array(); + } + + $pending = array(); + + foreach ($this->lock->FilteredPeers as $item) { + $pending[$item->Name] = $item->NodeKey; + } + + return $pending; + } + + public function getTailscaleLockWarning(): ?Warning + { + if ($this->getTailscaleLockEnabled() && ( ! $this->getTailscaleLockSigned())) { + return new Warning($this->tr("warnings.lock"), "error"); + } + return null; + } + + public function getTailscaleNetbiosWarning(): ?Warning + { + if (($this->useNetbios == "yes") && ($this->smbEnabled != "no")) { + return new Warning($this->tr("warnings.netbios"), "warn"); + } + return null; + } + + /** + * @return array + */ + public function getPeerStatus(): array + { + $result = array(); + + foreach ($this->status->Peer as $node => $status) { + $peer = new PeerStatus(); + + $peer->Name = trim($status->DNSName, "."); + $peer->IP = $status->TailscaleIPs; + + $peer->LoginName = $this->status->User->{$status->UserID}->LoginName; + $peer->SharedUser = isset($status->ShareeNode); + + if ($status->ExitNode) { + $peer->ExitNodeActive = true; + } elseif ($status->ExitNodeOption) { + $peer->ExitNodeAvailable = true; + } + $peer->Mullvad = in_array("tag:mullvad-exit-node", $status->Tags ?? array()); + + if ($status->TxBytes > 0 || $status->RxBytes > 0) { + $peer->Traffic = true; + $peer->TxBytes = $status->TxBytes; + $peer->RxBytes = $status->RxBytes; + } + + if ( ! $status->Online) { + $peer->Online = false; + $peer->Active = false; + } elseif ( ! $status->Active) { + $peer->Online = true; + $peer->Active = false; + } else { + $peer->Online = true; + $peer->Active = true; + + if (($status->Relay != "") && ($status->CurAddr == "")) { + $peer->Relayed = true; + $peer->Address = $status->Relay; + } elseif ($status->CurAddr != "") { + $peer->Relayed = false; + $peer->Address = $status->CurAddr; + } + } + + $result[] = $peer; + } + + return $result; + } + + public function advertisesExitNode(): bool + { + foreach (($this->prefs->AdvertiseRoutes ?? array()) as $net) { + switch ($net) { + case "0.0.0.0/0": + case "::/0": + return true; + } + } + + return false; + } + + public function usesExitNode(): bool + { + if (($this->prefs->ExitNodeID ?? "") || ($this->prefs->ExitNodeIP ?? "")) { + return true; + } + return false; + } + + public function exitNodeLocalAccess(): bool + { + return $this->prefs->ExitNodeAllowLANAccess ?? false; + } + + public function acceptsDNS(): bool + { + return $this->prefs->CorpDNS ?? false; + } + + public function acceptsRoutes(): bool + { + return $this->prefs->RouteAll ?? false; + } + + public function runsSSH(): bool + { + return $this->prefs->RunSSH ?? false; + } + + public function isOnline(): bool + { + return $this->status->Self->Online ?? false; + } + + public function getAuthURL(): string + { + return $this->status->AuthURL ?? ""; + } + + public function needsLogin(): bool + { + return ($this->status->BackendState ?? "") == "NeedsLogin"; + } + + /** + * @return array + */ + public function getAdvertisedRoutes(): array + { + $advertisedRoutes = $this->prefs->AdvertiseRoutes ?? array(); + $exitNodeRoutes = ["0.0.0.0/0", "::/0"]; + return array_diff($advertisedRoutes, $exitNodeRoutes); + } + + public function isApprovedRoute(string $route): bool + { + return in_array($route, $this->status->Self->AllowedIPs ?? array()); + } + + public function getTailnetName(): string + { + return $this->status->CurrentTailnet->Name ?? ""; + } + + /** + * @return array + */ + public function getExitNodes(): array + { + $exitNodes = array(); + + foreach (($this->status->Peer ?? array()) as $node => $status) { + if ($status->ExitNodeOption ?? false) { + $nodeName = $status->DNSName; + if (isset($status->Location->City)) { + $nodeName .= " (" . $status->Location->City . ")"; + } + $exitNodes[$status->ID] = $nodeName; + } + } + + return $exitNodes; + } + + public function getCurrentExitNode(): string + { + foreach (($this->status->Peer ?? array()) as $node => $status) { + if ($status->ExitNode ?? false) { + return $status->ID; + } + } + + return ""; + } + + public function connectedViaTS(): bool + { + return in_array($_SERVER['SERVER_ADDR'] ?? "", $this->status->TailscaleIPs ?? array()); + } + + /** + * @return array + */ + public function getAllowedFunnelPorts(): array + { + $allowedPorts = array(); + + if (isset($this->status->Self->CapMap)) { + $prefix = "https://tailscale.com/cap/funnel-ports?ports="; + foreach ($this->status->Self->CapMap as $cap => $value) { + if (strpos($cap, $prefix) === 0) { + $ports = explode(",", substr($cap, strlen($prefix))); + foreach ($ports as $port) { + $allowedPorts[] = intval($port); + } + break; + } + } + } + return $allowedPorts; + } + + public function getDNSName(): string + { + if ( ! isset($this->status->Self->DNSName)) { + throw new \RuntimeException("DNSName not set in Tailscale status."); + } + + return $this->status->Self->DNSName; + } + + public function isApprovedPeerRelay(): bool + { + $netmapRules = (array) $this->localAPI->getPacketFilterRules(); + + // Parse the packet filter rules to see if peer relay is approved + // TODO: Get a better way to do this from Tailscale + foreach ($netmapRules as $rule) { + if (isset($rule->CapGrant) && is_array($rule->CapGrant)) { + foreach ($rule->CapGrant as $capGrant) { + if (is_object($capGrant) && isset($capGrant->CapMap) && is_object($capGrant->CapMap) && isset($capGrant->CapMap->{'tailscale.com/cap/relay'})) { + return true; + } + } + } + } + + return false; + } + + public function getRelayServerPort(): int|false + { + if (isset($this->prefs->RelayServerPort) && is_int($this->prefs->RelayServerPort)) { + return $this->prefs->RelayServerPort; + } + return false; + } + + public function autoUpdateEnabled(): bool + { + return $this->prefs->AutoUpdate->Apply ?? false; + } +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php new file mode 100644 index 0000000..d0bf120 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php @@ -0,0 +1,239 @@ +. +*/ + +namespace Tailscale; + +enum APIMethods +{ + case GET; + case POST; + case PATCH; +} + +class LocalAPI +{ + private const tailscaleSocket = '/var/run/tailscale/tailscaled.sock'; + private Utils $utils; + + public function __construct() + { + if ( ! defined(__NAMESPACE__ . "\PLUGIN_ROOT") || ! defined(__NAMESPACE__ . "\PLUGIN_NAME")) { + throw new \RuntimeException("Common file not loaded."); + } + $this->utils = new Utils(PLUGIN_NAME); + } + + private function tailscaleLocalAPI(string $url, APIMethods $method = APIMethods::GET, object $body = new \stdClass()): string + { + if (empty($url)) { + throw new \InvalidArgumentException("URL cannot be empty"); + } + + $body_encoded = json_encode($body, JSON_UNESCAPED_SLASHES); + + if ( ! $body_encoded) { + throw new \InvalidArgumentException("Failed to encode JSON"); + } + + $ch = curl_init(); + + $headers = []; + + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_setopt($ch, CURLOPT_UNIX_SOCKET_PATH, $this::tailscaleSocket); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_URL, "http://local-tailscaled.sock/localapi/{$url}"); + + if ($method == APIMethods::POST) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body_encoded); + $this->utils->logmsg("Tailscale Local API: {$url} POST " . $body_encoded); + $headers[] = "Content-Type: application/json"; + } + + if ($method == APIMethods::PATCH) { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body_encoded); + $this->utils->logmsg("Tailscale Local API: {$url} PATCH " . $body_encoded); + $headers[] = "Content-Type: application/json"; + } + + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + $out = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($out === false) { + throw new \RuntimeException("Tailscale Local API request failed for URL: {$url}"); + } + + if ($http_code < 200 || $http_code >= 300) { + throw new \RuntimeException("Tailscale Local API returned HTTP {$http_code} for URL: {$url}"); + } + + return strval($out); + } + + private function decodeJSONResponse(string $response): \stdClass + { + $decoded = json_decode($response); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("Failed to decode JSON response: " . json_last_error_msg()); + } + + return (object) $decoded; + } + + public function isReady(): bool + { + // Check if tailscaleSocket exists + if ( ! file_exists($this::tailscaleSocket)) { + return false; + } + + // Check backend state from status endpoint + try { + $acceptedStates = ['Running', 'NeedsLogin', 'NeedsMachineAuth', 'Stopped']; + $status = $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/status')); + if (isset($status->BackendState) && in_array($status->BackendState, $acceptedStates, true)) { + return true; + } + } catch (\RuntimeException $e) { + // No need to log here, as this is just a readiness check + } + + return false; + } + + public function getStatus(): \stdClass + { + try { + return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/status')); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to get status: " . $e->getMessage()); + return new \stdClass(); + } + } + + public function getPrefs(): \stdClass + { + try { + return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/prefs')); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to get prefs: " . $e->getMessage()); + return new \stdClass(); + } + } + + public function getTkaStatus(): \stdClass + { + try { + return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/tka/status')); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to get TKA status: " . $e->getMessage()); + return new \stdClass(); + } + } + + public function getServeConfig(): ServeConfig + { + try { + return new ServeConfig($this->decodeJSONResponse($this->tailscaleLocalAPI('v0/serve-config'))); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to get serve config: " . $e->getMessage()); + return new ServeConfig(); + } + } + + public function getPacketFilterRules(): \stdClass + { + try { + return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/debug-packet-filter-rules')); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to get packet filter rules: " . $e->getMessage()); + return new \stdClass(); + } + } + + public function setServeConfig(ServeConfig $serveConfig): void + { + try { + $this->tailscaleLocalAPI("v0/serve-config", APIMethods::POST, $serveConfig->getConfig()); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to set serve config: " . $e->getMessage()); + } + } + + public function postLoginInteractive(): void + { + try { + $this->tailscaleLocalAPI('v0/login-interactive', APIMethods::POST); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to post login interactive: " . $e->getMessage()); + } + } + + public function patchPref(string $key, mixed $value): void + { + $body = []; + $body[$key] = $value; + $body["{$key}Set"] = true; + + try { + $this->tailscaleLocalAPI('v0/prefs', APIMethods::PATCH, (object) $body); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to patch pref {$key}: " . $e->getMessage()); + } + } + + public function postTkaSign(string $key): void + { + $body = ["NodeKey" => $key]; + + try { + $this->tailscaleLocalAPI("v0/tka/sign", APIMethods::POST, (object) $body); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to sign TKA key: " . $e->getMessage()); + } + } + + public function expireKey(): void + { + try { + $this->tailscaleLocalAPI('v0/set-expiry-sooner?expiry=0', APIMethods::POST); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to expire key: " . $e->getMessage()); + } + } + + public function setAutoUpdate(bool $enabled): void + { + $body = []; + $body["AutoUpdate"] = ["Apply" => $enabled, "Check" => $enabled]; + $body["AutoUpdateSet"] = ["ApplySet" => true, "CheckSet" => true]; + + try { + $this->tailscaleLocalAPI("v0/prefs", APIMethods::PATCH, (object) $body); + } catch (\RuntimeException $e) { + $this->utils->logmsg("Failed to set AutoUpdate: " . $e->getMessage()); + } + } +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LockInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LockInfo.php new file mode 100644 index 0000000..3e323c6 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LockInfo.php @@ -0,0 +1,28 @@ +. +*/ + +namespace Tailscale; + +class LockInfo +{ + public string $LockSigned = ""; + public string $LockSigning = ""; + public string $PubKey = ""; + public string $NodeKey = ""; +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/PeerStatus.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/PeerStatus.php new file mode 100644 index 0000000..ba63cb0 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/PeerStatus.php @@ -0,0 +1,44 @@ +. +*/ + +namespace Tailscale; + +class PeerStatus +{ + public string $Name = ""; + public string $LoginName = ""; + public bool $SharedUser = false; + + /** @var string[] */ + public array $IP = array(); + + public string $Address = ""; + + public bool $Online = false; + public bool $Active = false; + public bool $Relayed = false; + + public bool $Traffic = false; + public int $TxBytes = 0; + public int $RxBytes = 0; + + public bool $ExitNodeActive = false; + public bool $ExitNodeAvailable = false; + public bool $Mullvad = false; +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php new file mode 100644 index 0000000..7673f2e --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php @@ -0,0 +1,226 @@ +. +*/ + +namespace Tailscale; + +class ServeConfig +{ + private \stdClass $config; + + private const CONFIG_FILE = '/boot/config/plugins/tailscale/funnel.json'; + + public function __construct(?\stdClass $config = null) + { + $this->config = $config ?? new \stdClass(); + } + + public function configureFunnel(string $hostname, string $port, string $target): void + { + // Validate the hostname + if ( ! filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + throw new \InvalidArgumentException("Invalid hostname: {$hostname}"); + } + + // Validate the port + if ( ! is_numeric($port) || (int)$port < 1 || (int)$port > 65535) { + throw new \InvalidArgumentException("Invalid port: {$port}"); + } + + $hostAndPort = "{$hostname}:{$port}"; + + // Ensure TCP exists + if ( ! isset($this->config->TCP)) { + $this->config->TCP = new \stdClass(); + } + // Ensure the specific port exists + if ( ! isset($this->config->TCP->{$port})) { + $this->config->TCP->{$port} = new \stdClass(); + } + $this->config->TCP->{$port}->HTTPS = true; + + // Ensure Web exists + if ( ! isset($this->config->Web)) { + $this->config->Web = new \stdClass(); + } + // Ensure the specific hostAndPort exists + if ( ! isset($this->config->Web->{$hostAndPort})) { + $this->config->Web->{$hostAndPort} = new \stdClass(); + } + // Ensure Handlers exists + if ( ! isset($this->config->Web->{$hostAndPort}->Handlers)) { + $this->config->Web->{$hostAndPort}->Handlers = new \stdClass(); + } + // Ensure the root handler exists + if ( ! isset($this->config->Web->{$hostAndPort}->Handlers->{'/'})) { + $this->config->Web->{$hostAndPort}->Handlers->{'/'} = new \stdClass(); + } + $this->config->Web->{$hostAndPort}->Handlers->{'/'}->Proxy = $target; + + // Ensure AllowFunnel exists + if ( ! isset($this->config->AllowFunnel)) { + $this->config->AllowFunnel = new \stdClass(); + } + $this->config->AllowFunnel->{$hostAndPort} = true; + } + + public function getConfig(): \stdClass + { + return $this->config; + } + + public function removeServeByPort(string $port): void + { + // Remove TCP configuration for the port if it exists + if (isset($this->config->TCP->{$port})) { + unset($this->config->TCP->{$port}); + } + + // Remove Web configurations matching this port + if (isset($this->config->Web)) { + foreach ($this->config->Web as $fqdn => $_) { + if (str_ends_with($fqdn, ":{$port}")) { + unset($this->config->Web->{$fqdn}); + + // Also remove corresponding AllowFunnel entry + if (isset($this->config->AllowFunnel->{$fqdn})) { + unset($this->config->AllowFunnel->{$fqdn}); + } + } + } + } + } + + public function removeFunnel(string $hostname, string $port): void + { + $hostAndPort = "{$hostname}:{$port}"; + + // Remove from TCP if it exists + if (isset($this->config->TCP->{$port})) { + unset($this->config->TCP->{$port}); + } + + // Remove from Web if it exists + if (isset($this->config->Web->{$hostAndPort})) { + unset($this->config->Web->{$hostAndPort}); + } + + // Remove from AllowFunnel if it exists + if (isset($this->config->AllowFunnel->{$hostAndPort})) { + unset($this->config->AllowFunnel->{$hostAndPort}); + } + } + + public function resetFunnel(): void + { + if (isset($this->config->AllowFunnel)) { + unset($this->config->AllowFunnel); + } + } + + public function hasFunnel(): bool + { + return isset($this->config->AllowFunnel); + } + + public function saveWebguiPort(string $port): void + { + if ($port != "") { + $backupCfg = array('webgui_port' => $port); + file_put_contents(self::CONFIG_FILE, json_encode($backupCfg)); + } else { + if (file_exists(self::CONFIG_FILE)) { + unlink(self::CONFIG_FILE); + } + } + } + + public function getWebguiPort(): ?string + { + if ( ! file_exists(self::CONFIG_FILE)) { + return null; + } + + $backupCfg = json_decode(file_get_contents(self::CONFIG_FILE) ?: "", true); + + if ($backupCfg === null || ! is_array($backupCfg)) { + return null; + } + + if ( ! isset($backupCfg['webgui_port']) || ! is_string($backupCfg['webgui_port'])) { + return null; + } + + return $backupCfg['webgui_port']; + } + + public function getFunnelPort(string $hostname, ?string $port = null): ?string + { + $serveConfig = $this->config; + if ( ! isset($serveConfig->AllowFunnel) || ! $serveConfig->AllowFunnel) { + return null; // Funnel not enabled + } + + if ($port === null) { + // Get the expected target from ident.cfg + $identCfg = parse_ini_file("/boot/config/ident.cfg", false, INI_SCANNER_RAW) ?: array(); + if ( ! isset($identCfg['PORT'])) { + return null; // Can't determine expected target without ident.cfg PORT + } + $port = $identCfg['PORT']; + } + + return $this->searchFunnelPort($hostname, $port); + } + + private function searchFunnelPort(string $hostname, string $port): ?string + { + $serveConfig = $this->config; + $expectedTarget = "http://localhost:" . $port; + + // Get the FQDN (DNS name without trailing dot) + $fqdn = trim($hostname, "."); + + // Look for a funnel entry that matches our FQDN and has a corresponding Web entry + foreach ($serveConfig->AllowFunnel as $hostAndPort => $_) { + // Check if this entry starts with our FQDN followed by a colon and port + if (str_starts_with($hostAndPort, $fqdn . ":")) { + // Verify this is an "old serve" funnel by checking for a Web entry + if (isset($serveConfig->Web->{$hostAndPort})) { + // Verify the target matches the expected ident.cfg target + if (isset($serveConfig->Web->{$hostAndPort}->Handlers->{'/'}->Proxy) && $serveConfig->Web->{$hostAndPort}->Handlers->{'/'}->Proxy === $expectedTarget) { + // Extract the port from the hostAndPort + $parts = explode(":", strval($hostAndPort)); + if (count($parts) == 2 && is_numeric($parts[1])) { + return strval($parts[1]); + } + } + } + } + } + + return null; + } + + public function updateWebProxy(string $hostAndPort, string $newTarget): void + { + if (isset($this->config->Web->{$hostAndPort}->Handlers->{'/'}->Proxy)) { + $this->config->Web->{$hostAndPort}->Handlers->{'/'}->Proxy = $newTarget; + } + } +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/StatusInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/StatusInfo.php new file mode 100644 index 0000000..102bd87 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/StatusInfo.php @@ -0,0 +1,33 @@ +. +*/ + +namespace Tailscale; + +class StatusInfo +{ + public ?LockInfo $LockInfo = null; + public string $TsVersion = ""; + public string $KeyExpiration = ""; + public string $Online = ""; + public string $InNetMap = ""; + public string $Tags = ""; + public string $LoggedIn = ""; + public string $TsHealth = ""; + public string $LockEnabled = ""; +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php new file mode 100644 index 0000000..adb39f3 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php @@ -0,0 +1,464 @@ +. +*/ + +namespace Tailscale; + +use EDACerton\PluginUtils\Translator; + +enum NotificationType: string +{ + case NORMAL = 'normal'; + case WARNING = 'warning'; + case ALERT = 'alert'; +} + +class System extends \EDACerton\PluginUtils\System +{ + public const RESTART_COMMAND = "/usr/local/emhttp/webGui/scripts/reload_services"; + public const NOTIFY_COMMAND = "/usr/local/emhttp/webGui/scripts/notify"; + + public static function addToHostFile(\stdClass $status): void + { + // Add self to /etc/hosts + if (isset($status->Self->DNSName) && isset($status->Self->TailscaleIPs) && is_array($status->Self->TailscaleIPs)) { + foreach ($status->Self->TailscaleIPs as $ip) { + if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + Utils::logwrap("Adding self {$status->Self->DNSName} with IP {$ip} to hosts file"); + self::updateHostsFile(rtrim($status->Self->DNSName, '.'), $ip); + } + } + } else { + Utils::logwrap("Self DNSName or TailscaleIPs not found, skipping self addition to hosts file."); + } + + // Add all peers to /etc/hosts, except those with the tag 'tag:mullvad-exit-node' + if (isset($status->Peer) && is_object($status->Peer)) { + foreach ((array)$status->Peer as $k => $peer) { + if ( ! ($peer instanceof \stdClass)) { + continue; + } + if (isset($peer->Tags) && is_array($peer->Tags) && in_array('tag:mullvad-exit-node', $peer->Tags, true)) { + continue; + } + if (isset($peer->DNSName) && isset($peer->TailscaleIPs) && is_array($peer->TailscaleIPs)) { + foreach ($peer->TailscaleIPs as $ip) { + if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + Utils::logwrap("Adding peer {$peer->DNSName} with IP {$ip} to hosts file"); + self::updateHostsFile(rtrim($peer->DNSName, '.'), $ip); + } + } + } + } + } else { + Utils::logwrap("No peers found to add to hosts file."); + } + } + + public static function fixLocalSubnetRoutes(): void + { + $ips = parse_ini_file("/boot/config/network.cfg") ?: array(); + if (array_key_exists(('IPADDR'), $ips)) { + $route_table = Utils::runwrap("ip route list table 52", false, false); + + $ipaddr = is_array($ips['IPADDR']) ? $ips['IPADDR'] : array($ips['IPADDR']); + + foreach ($ipaddr as $ip) { + foreach ($route_table as $route) { + $net = explode(' ', $route)[0]; + if (Utils::ip4_in_network($ip, $net)) { + Utils::logwrap("Detected local IP {$ip} in Tailscale route {$net}, removing"); + Utils::runwrap("ip route del '{$net}' dev tailscale1 table 52"); + } + } + } + } + } + + public static function checkWebgui(Config $config, string $tailscale_ipv4, bool $allowRestart): bool + { + // Make certain that the WebGUI is listening on the Tailscale interface + if ($config->IncludeInterface) { + $ident_config = parse_ini_file("/boot/config/ident.cfg") ?: array(); + + $connection = @fsockopen($tailscale_ipv4, $ident_config['PORT']); + + if (is_resource($connection)) { + Utils::logwrap("WebGUI listening on {$tailscale_ipv4}:{$ident_config['PORT']}", false, true); + } else { + if ( ! $allowRestart) { + Utils::logwrap("WebGUI not listening on {$tailscale_ipv4}:{$ident_config['PORT']}, waiting for next check"); + return true; + } + + Utils::logwrap("WebGUI not listening on {$tailscale_ipv4}:{$ident_config['PORT']}, terminating and restarting"); + Utils::runwrap("/etc/rc.d/rc.nginx term"); + sleep(5); + Utils::runwrap("/etc/rc.d/rc.nginx start"); + } + } + + return false; + } + + public static function checkServeConfig(Config $config): void + { + $ident_config = parse_ini_file("/boot/config/ident.cfg") ?: array(); + + $httpPort = isset($ident_config['PORT']) && is_scalar($ident_config['PORT']) + ? intval($ident_config['PORT']) : 80; + $httpsPort = isset($ident_config['PORTSSL']) && is_scalar($ident_config['PORTSSL']) + ? intval($ident_config['PORTSSL']) : 443; + + $localAPI = new LocalAPI(); + $serveConfig = $localAPI->getServeConfig(); + + $tcpConfig = $serveConfig->getConfig()->TCP ?? array(); + + foreach ($tcpConfig as $key => $val) { + $configPort = intval($key); + + if ($configPort == $httpPort || $configPort == $httpsPort) { + Utils::logwrap("Serve TCP Port {$configPort} conflicts with WebGUI, removing"); + self::sendNotification( + "Tailscale Serve Port Conflict", + "Tailscale Serve Port Conflict", + "Port {$configPort} conflicts with WebGUI port. The Tailscale serve config is being updated to remove the conflict.", + NotificationType::ALERT + ); + + $serveConfig->removeServeByPort($key); + $localAPI->setServeConfig($serveConfig); + + Utils::runwrap(self::RESTART_COMMAND); + + return; + } else { + Utils::logwrap("Checked for WebGUI conflict with serve TCP Port {$configPort}", false, true); + } + } + + // Check if serveConfig has an AllowFunnel property. If this exists, but Config->AllowFunnel is false, reset the config. + // This should only be done if the Unraid version is 7.2 or later, as earlier versions do not display the config setting. + $vars = parse_ini_file('/usr/local/emhttp/state/var.ini'); + if (version_compare($vars['version'] ?? "", '7.2', '>=')) { + if ($serveConfig->hasFunnel() && $config->AllowFunnel === false) { + Utils::logwrap("Tailscale funnel is enabled, but config does not allow it, resetting serve config"); + + // Get the hostname and funnel port, then remove the funnel from the serve config + // We need an Info object to get the hostname + $info = new Info(null); + + $hostname = trim($info->getDNSName(), "."); + $currentFunnelPort = $serveConfig->getFunnelPort($hostname); + + if ($currentFunnelPort != '') { + $serveConfig->removeFunnel($hostname, $currentFunnelPort); + } + + // Remove any remaining funnels, but leave the serve part + $serveConfig->resetFunnel(); + $localAPI->setServeConfig($serveConfig); + } + } + } + + public static function restartSystemServices(Config $config): void + { + if ($config->IncludeInterface) { + self::refreshWebGuiCert(false); + + Utils::runwrap(self::RESTART_COMMAND); + } + + if (file_exists('/etc/rc.d/rc.tsidp')) { + Utils::runwrap('/etc/rc.d/rc.tsidp restart'); + } + } + + public static function enableIPForwarding(Config $config): void + { + if ($config->Enable) { + Utils::logwrap("Enabling IP forwarding"); + $sysctl = "net.ipv4.ip_forward = 1" . PHP_EOL . "net.ipv6.conf.all.forwarding = 1"; + file_put_contents('/etc/sysctl.d/99-tailscale.conf', $sysctl); + Utils::runwrap("sysctl -p /etc/sysctl.d/99-tailscale.conf", true); + } + } + + public static function applyGRO(): void + { + /** @var array> $ip_route */ + $ip_route = (array) json_decode(implode(Utils::runwrap('ip -j route get 8.8.8.8')), true); + + // Check if a device was returned + if ( ! isset($ip_route[0]['dev'])) { + Utils::logwrap("Default interface could not be detected."); + return; + } + + $dev = $ip_route[0]['dev']; + + /** @var array> $ethtool */ + $ethtool = ((array) json_decode(implode(Utils::runwrap("ethtool --json -k {$dev}")), true))[0]; + + if (isset($ethtool['rx-udp-gro-forwarding']) && ! $ethtool['rx-udp-gro-forwarding']['active']) { + Utils::runwrap("ethtool -K {$dev} rx-udp-gro-forwarding on"); + } + + if (isset($ethtool['rx-gro-list']) && $ethtool['rx-gro-list']['active']) { + Utils::runwrap("ethtool -K {$dev} rx-gro-list off"); + } + } + + public static function notifyOnKeyExpiration(): void + { + $localAPI = new LocalAPI(); + $status = $localAPI->getStatus(); + + if (isset($status->Self->KeyExpiry)) { + $expiryTime = new \DateTime($status->Self->KeyExpiry); + $expiryTime->setTimezone(new \DateTimeZone(date_default_timezone_get())); + $interval = $expiryTime->diff(new \DateTime('now')); + + $expiryPrint = $expiryTime->format(\DateTimeInterface::RFC7231); + $intervalPrint = $interval->format('%a'); + + $message = "The Tailscale key will expire in {$intervalPrint} days on {$expiryPrint}."; + Utils::logwrap($message); + + switch (true) { + case $interval->days <= 7: + $priority = NotificationType::ALERT; + break; + case $interval->days <= 30: + $priority = NotificationType::WARNING; + break; + default: + return; + } + + $event = "Tailscale Key Expiration - {$priority->value} - {$expiryTime->format('Ymd')}"; + Utils::logwrap("Sending notification for key expiration: {$event}"); + self::sendNotification($event, "Tailscale key is expiring", $message, $priority); + } else { + Utils::logwrap("Tailscale key expiration is not set."); + } + } + + public static function sendNotification(string $event, string $subject, string $message, NotificationType $priority): void + { + $command = self::NOTIFY_COMMAND . " -l '/Settings/Tailscale' -e " . escapeshellarg($event) . " -s " . escapeshellarg($subject) . " -d " . escapeshellarg("{$message}") . " -i \"{$priority->value}\" -x 2>/dev/null"; + exec($command); + } + + public static function refreshWebGuiCert(bool $restartIfChanged = true): void + { + $localAPI = new LocalAPI(); + $status = $localAPI->getStatus(); + + $certDomains = $status->CertDomains; + + if (count($certDomains ?? array()) === 0) { + Utils::logwrap("Cannot generate certificate for WebGUI -- HTTPS not enabled for Tailnet."); + return; + } + + $dnsName = $certDomains[0]; + + $certFile = "/boot/config/plugins/tailscale/state/certs/{$dnsName}.crt"; + $keyFile = "/boot/config/plugins/tailscale/state/certs/{$dnsName}.key"; + $pemFile = "/boot/config/ssl/certs/ts_bundle.pem"; + + clearstatcache(); + + $pemHash = ''; + if (file_exists($pemFile)) { + $pemHash = sha1_file($pemFile); + } + + Utils::logwrap("Certificate bundle hash: {$pemHash}"); + + Utils::runwrap("tailscale cert --cert-file={$certFile} --key-file={$keyFile} --min-validity=720h {$dnsName}"); + + if ( + file_exists($certFile) && file_exists($keyFile) && filesize($certFile) > 0 && filesize($keyFile) > 0 + ) { + file_put_contents($pemFile, file_get_contents($certFile)); + file_put_contents($pemFile, file_get_contents($keyFile), FILE_APPEND); + + if ((sha1_file($pemFile) != $pemHash) && $restartIfChanged) { + Utils::logwrap("WebGUI certificate has changed, restarting nginx"); + Utils::runwrap("/etc/rc.d/rc.nginx reload"); + } + } else { + Utils::logwrap("Something went wrong when creating WebGUI certificate, skipping nginx update."); + } + } + + public static function setExtraInterface(Config $config): void + { + if (file_exists(self::RESTART_COMMAND)) { + $include_array = array(); + $exclude_interfaces = ""; + $write_file = true; + $network_extra_file = '/boot/config/network-extra.cfg'; + $ifname = 'tailscale1'; + + if (file_exists($network_extra_file)) { + $netExtra = parse_ini_file($network_extra_file); + if ($netExtra['include_interfaces'] ?? false) { + $include_array = explode(' ', $netExtra['include_interfaces']); + } + if ($netExtra['exclude_interfaces'] ?? false) { + $exclude_interfaces = $netExtra['exclude_interfaces']; + } + $write_file = false; + } + + $in_array = in_array($ifname, $include_array); + + if ($in_array != $config->IncludeInterface) { + if ($config->IncludeInterface) { + $include_array[] = $ifname; + Utils::logwrap("{$ifname} added to include_interfaces"); + } else { + $include_array = array_diff($include_array, [$ifname]); + Utils::logwrap("{$ifname} removed from include_interfaces"); + } + $write_file = true; + } + + if ($write_file) { + $include_interfaces = implode(' ', $include_array); + + $file = <<patchPref($flag, false); + } + } + + public static function applyTailscaleConfig(Config $config): void + { + $localAPI = new LocalAPI(); + + self::disableTailscaleFeature($localAPI, $config->AllowRoutes, 'RouteAll'); + self::disableTailscaleFeature($localAPI, $config->AllowDNS, 'CorpDNS'); + + $localAPI->patchPref('NoStatefulFiltering', true); + } + + public static function createTailscaledParamsFile(Config $config): void + { + $custom_params = ""; + + if ($config->WgPort > 0 && $config->WgPort < 65535) { + $custom_params .= "-port {$config->WgPort} "; + } + + if ($config->NoLogsNoSupport) { + $custom_params .= "-no-logs-no-support "; + } + + if ( ! $config->UseTPM) { + $custom_params .= "-encrypt-state=false -hardware-attestation=false "; + } + + file_put_contents('/usr/local/emhttp/plugins/tailscale/custom-params.sh', 'TAILSCALE_CUSTOM_PARAMS="' . $custom_params . '"'); + } + + public static function createTaildropLink(Config $config): void + { + $linkPath = '/var/lib/tailscale/Taildrop'; + if (is_link($linkPath) || file_exists($linkPath)) { + unlink($linkPath); + } + + if ( ! empty($config->TaildropDir) && is_dir($config->TaildropDir) && is_writable($config->TaildropDir)) { + // Create parent directory if it does not exist + $parentDir = dirname($linkPath); + if ( ! is_dir($parentDir)) { + mkdir($parentDir, 0755, true); + } + + if (symlink($config->TaildropDir, $linkPath)) { + Utils::logwrap("Created Taildrop link from {$linkPath} to {$config->TaildropDir}"); + } else { + Utils::logwrap("Failed to create Taildrop link from {$linkPath} to {$config->TaildropDir}"); + } + } else { + Utils::logwrap("Taildrop directory is not set, does not exist, or is not writable, skipping link creation."); + } + } + + public static function checkFunnelPort(Config $config): void + { + if ( ! $config->AllowFunnel) { + return; + } + + // Check if the current port from ident.cfg matches the saved port in the ServeConfig + $localAPI = new LocalAPI(); + $serveConfig = $localAPI->getServeConfig(); + $tailscaleInfo = new Info(null); + $hostname = trim($tailscaleInfo->getDNSName(), "."); + + // Get the current port from ident.cfg + $identCfg = parse_ini_file("/boot/config/ident.cfg", false, INI_SCANNER_RAW) ?: array(); + if ( ! isset($identCfg['PORT'])) { + return; // Can't determine expected target without ident.cfg PORT + } + $currentPort = $identCfg['PORT']; + + $savedPort = $serveConfig->getWebguiPort(); + if ($savedPort === null) { + return; + } + + if ($currentPort !== $savedPort) { + Utils::logwrap("WebGUI port has changed from {$savedPort} to {$currentPort}, updating funnel configuration"); + + $funnelPort = $serveConfig->getFunnelPort($hostname, $savedPort); + if ($funnelPort === null) { + Utils::logwrap("Could not retrieve funnel port, skipping update"); + return; + } + + $serveConfig->updateWebProxy("{$hostname}:{$funnelPort}", "http://localhost:{$currentPort}"); + $localAPI->setServeConfig($serveConfig); + + // Update the saved port to the current port + $serveConfig->saveWebguiPort($currentPort); + } + } +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Utils.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Utils.php new file mode 100644 index 0000000..f3043d8 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Utils.php @@ -0,0 +1,203 @@ +. +*/ + +namespace Tailscale; + +use PhpIP\IPBlock; +use EDACerton\PluginUtils\Translator; + +class Utils extends \EDACerton\PluginUtils\Utils +{ + public function setPHPDebug(): void + { + $debug = file_exists("/boot/config/plugins/tailscale/debug"); + + if ($debug && ! defined("PLUGIN_DEBUG")) { + error_reporting(E_ALL); + define("PLUGIN_DEBUG", true); + } + } + + public static function printRow(string $title, string $value): string + { + return "{$title}{$value}" . PHP_EOL; + } + + public static function printDash(string $title, string $value): string + { + return "{$title}{$value}" . PHP_EOL; + } + + public static function formatWarning(?Warning $warning): string + { + if ($warning == null) { + return ""; + } + + return "" . $warning->Message . ""; + } + + public static function ip4_in_network(string $ip, string $network): bool + { + if (strpos($network, '/') === false) { + return false; + } + + list($subnet, $mask) = explode('/', $network, 2); + $ip_bin_string = sprintf("%032b", ip2long($ip)); + $net_bin_string = sprintf("%032b", ip2long($subnet)); + + return (substr_compare($ip_bin_string, $net_bin_string, 0, intval($mask)) === 0); + } + + public static function logwrap(string $message, bool $debug = false, bool $rateLimit = false): void + { + if ( ! defined(__NAMESPACE__ . "\PLUGIN_NAME")) { + throw new \RuntimeException("PLUGIN_NAME is not defined."); + } + $utils = new Utils(PLUGIN_NAME); + $utils->logmsg($message, $debug, $rateLimit); + } + + /** + * @return array + */ + public static function runwrap(string $command, bool $alwaysShow = false, bool $show = true): array + { + if ( ! defined(__NAMESPACE__ . "\PLUGIN_NAME")) { + throw new \RuntimeException("PLUGIN_NAME is not defined."); + } + $utils = new Utils(PLUGIN_NAME); + return $utils->run_command($command, $alwaysShow, $show); + } + + public static function validateCidr(string $cidr): bool + { + try { + $block = IPBlock::create($cidr); + + // Check that the IP address is the network address (host bits are zero) + return $block->getNetworkAddress()->humanReadable() . '/' . $block->getPrefixLength() === $cidr; + } catch (\Exception $e) { + return false; + } + } + + /** + * @return array + */ + public static function getExitRoutes(): array + { + return ["0.0.0.0/0", "::/0"]; + } + + public static function isFunnelAllowed(): bool + { + $directives = ["allow 127.0.0.1;", "allow ::1;"]; + + $nginxConfig = file_get_contents('/etc/nginx/nginx.conf'); + if ($nginxConfig === false) { + return false; // Unable to read the nginx configuration file + } + + // Search $nginxConfig for the allow directives. + foreach ($directives as $directive) { + if (strpos($nginxConfig, $directive) !== false) { + return false; // Directive found, funnel not safe to use + } + } + + return true; + } + + /** + * Get a list of ports that are currently assigned to services. + * This is a best-effort approach, especially since docker might not be running during configuration. + * + * @return array + */ + public function get_assigned_ports(): array + { + $ports = array(); + $identCfg = parse_ini_file("/boot/config/ident.cfg", false, INI_SCANNER_RAW) ?: array(); + if (isset($identCfg['PORT'])) { + $ports[] = intval($identCfg['PORT']); + } + if (isset($identCfg['PORTSSL']) && isset($identCfg['USE_SSL']) && $identCfg['USE_SSL'] === 'yes') { + $ports[] = intval($identCfg['PORTSSL']); + } + if (isset($identCfg['PORTTELNET']) && isset($identCfg['USE_TELNET']) && $identCfg['USE_TELNET'] === 'yes') { + $ports[] = intval($identCfg['PORTTELNET']); + } + if (isset($identCfg['PORTSSH']) && isset($identCfg['USE_SSH']) && $identCfg['USE_SSH'] === 'yes') { + $ports[] = intval($identCfg['PORTSSH']); + } + + // Get any open TCP ports from the system + $netstatOutput = shell_exec("netstat -tuln | grep LISTEN"); + if ($netstatOutput) { + $lines = explode("\n", trim($netstatOutput)); + foreach ($lines as $line) { + if (preg_match('/:(\d+)\s+/', $line, $matches)) { + $port = intval($matches[1]); + if ($port > 0 && $port < 65536) { + $ports[] = $port; + } + } + } + } + + return array_unique($ports); + } + + public static function pageChecks(Translator $tr): bool + { + static $config = null; + static $localAPI = null; + + if ($config === null) { + $config = new Config(); + } + if ($localAPI === null) { + $localAPI = new LocalAPI(); + } + + if ( ! $config->Enable) { + echo($tr->tr("tailscale_disabled")); + return false; + } + + if ( ! $localAPI->isReady()) { + echo($tr->tr("warnings.not_ready")); + echo(<< + $(function() { + setTimeout(function() { + window.location = window.location.href; + }, 5000); + }); + + EOT + ); + return false; + } + + return true; + } +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Warning.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Warning.php new file mode 100644 index 0000000..366f900 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Warning.php @@ -0,0 +1,32 @@ +. +*/ + +namespace Tailscale; + +class Warning +{ + public string $Message; + public string $Priority; + + public function __construct(string $message = "", string $priority = "system") + { + $this->Message = $message; + $this->Priority = $priority; + } +} diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php new file mode 100644 index 0000000..d04aeb9 --- /dev/null +++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php @@ -0,0 +1,109 @@ +. +*/ + +namespace Tailscale; + +class Watcher +{ + private Config $config; + + public function __construct() + { + $this->config = new Config(); + } + + public function run(): void + { + $timer = 15; + $need_ip = true; + $allow_check_restart = false; + + $tsName = ''; + + if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) { + throw new \RuntimeException("Common file not loaded."); + } + $utils = new Utils(PLUGIN_NAME); + + $utils->logmsg("Starting tailscale-watcher"); + + while ( ! file_exists('/var/local/emhttp/var.ini')) { + $utils->logmsg("Waiting for system to finish booting"); + sleep(10); + } + + // @phpstan-ignore while.alwaysTrue + while (true) { + unset($tailscale_ipv4); + + $interfaces = net_get_interfaces(); + + if (isset($interfaces["tailscale1"]["unicast"])) { + foreach ($interfaces["tailscale1"]["unicast"] as $interface) { + if (isset($interface["address"])) { + if ($interface["family"] == 2) { + $tailscale_ipv4 = $interface["address"]; + $timer = 60; + } + } + } + } + + if (isset($tailscale_ipv4)) { + if ($need_ip) { + $utils->logmsg("Tailscale IP detected, applying configuration"); + $need_ip = false; + + $localAPI = new LocalAPI(); + $status = $localAPI->getStatus(); + $tsName = $status->Self->DNSName; + + $utils->run_task('Tailscale\System::applyTailscaleConfig', array($this->config)); + $utils->run_task('Tailscale\System::applyGRO'); + $utils->run_task('Tailscale\System::restartSystemServices', array($this->config)); + if ($this->config->AddPeersToHosts) { + $utils->run_task('Tailscale\System::addToHostFile', array($status)); + } + } + + $allow_check_restart = $utils->run_task('Tailscale\System::checkWebgui', array($this->config, $tailscale_ipv4, $allow_check_restart)); + $utils->run_task('Tailscale\System::checkServeConfig', array($this->config)); + $utils->run_task('Tailscale\System::fixLocalSubnetRoutes'); + $utils->run_task('Tailscale\System::checkFunnelPort', array($this->config)); + + // Watch for changes to the DNS name (e.g., if someone changes the tailnet name or the Tailscale name of the server via the admin console) + // If a change happens, refresh the Tailscale WebGUI certificate + $localAPI = new LocalAPI(); + $status = $localAPI->getStatus(); + $newTsName = $status->Self->DNSName; + + if ($newTsName != $tsName) { + $utils->logmsg("Detected DNS name change"); + $tsName = $newTsName; + + $utils->run_task('Tailscale\System::refreshWebGuiCert'); + } + } else { + $utils->logmsg("Waiting for Tailscale IP"); + } + + sleep($timer); + } + } +} diff --git a/tools/build-plugin.sh b/tools/build-plugin.sh deleted file mode 100755 index d37e214..0000000 --- a/tools/build-plugin.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh - -FILENAME=tailscale; jinja -d plugin/tailscale.json -D filename $FILENAME -D branch main plugin/tailscale.j2 > ../plugin/$FILENAME.plg -FILENAME=tailscale-preview; jinja -d plugin/tailscale.json -D filename $FILENAME -D branch preview plugin/tailscale.j2 > ../plugin/$FILENAME.plg -FILENAME=tailscale-trunk; jinja -d plugin/tailscale.json -D filename $FILENAME -D branch trunk plugin/tailscale.j2 > ../plugin/$FILENAME.plg \ No newline at end of file diff --git a/tools/plugin/files/CHANGELOG.md b/tools/plugin/files/CHANGELOG.md deleted file mode 100644 index b8d250e..0000000 --- a/tools/plugin/files/CHANGELOG.md +++ /dev/null @@ -1,17 +0,0 @@ -###2025.05.25### -- Remove usage reporting - -###2025.05.21### - -- Update Tailscale to 1.84.0 -- Update localization files - -###2025.05.08### - -- Fix: advertising exit node on 7.1 - -###2025.05.06a### - -- Monitor serve config for port conflicts with WebGUI - -For older releases, see https://github.com/unraid/unraid-tailscale/releases diff --git a/tools/plugin/files/install.sh b/tools/plugin/files/install.sh deleted file mode 100644 index 1705cf1..0000000 --- a/tools/plugin/files/install.sh +++ /dev/null @@ -1,73 +0,0 @@ -if [ -d "{{ pluginDirectory }}" ]; then - rm -rf {{ pluginDirectory }} -fi - -upgradepkg --install-new --reinstall {{ configDirectory }}/unraid-tailscale-utils-{{ packageVersion }}-noarch-1.txz - -mkdir -p {{ pluginDirectory }}/bin -tar xzf {{ configDirectory }}/{{ tailscaleVersion }}.tgz --strip-components 1 -C {{ pluginDirectory }}/bin - -echo "state" > {{ configDirectory }}/.gitignore - -ln -s {{ pluginDirectory }}/bin/tailscale /usr/local/sbin/tailscale -ln -s {{ pluginDirectory }}/bin/tailscaled /usr/local/sbin/tailscaled - -mkdir -p /var/local/emhttp/plugins/tailscale -echo "VERSION={{ version }}" > /var/local/emhttp/plugins/tailscale/tailscale.ini -echo "BRANCH={{ branch }}" >> /var/local/emhttp/plugins/tailscale/tailscale.ini - -# remove other branches (e.g., if switching from main to preview) -{% if branch != 'main' -%} -rm -f /boot/config/plugins/tailscale.plg -rm -f /var/log/plugins/tailscale.plg -{% endif -%} -{% if branch != 'preview' -%} -rm -f /boot/config/plugins/tailscale-preview.plg -rm -f /var/log/plugins/tailscale-preview.plg -{% endif -%} -{% if branch != 'trunk' -%} -rm -f /boot/config/plugins/tailscale-trunk.plg -rm -f /var/log/plugins/tailscale-trunk.plg -{% endif %} - -{% if branch != 'main' -%} -# Update plugin name for non-main branches -sed -i "s/Tailscale\*\*/Tailscale ({{ branch.capitalize() }})**/" {{ pluginDirectory }}/README.md -{% endif %} - -# start tailscaled -{{ pluginDirectory }}/restart.sh - -# Bash completion -tailscale completion bash > /etc/bash_completion.d/tailscale - -# cleanup old versions -rm -f /boot/config/plugins/{{ name }}/tailscale-utils-*.txz -rm -f $(ls /boot/config/plugins/{{ name }}/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '{{ packageVersion }}') -rm -f $(ls /boot/config/plugins/{{ name }}/unraid-plugin-diagnostics-*.txz 2>/dev/null) -rm -f $(ls /boot/config/plugins/{{ name }}/*.tgz 2>/dev/null | grep -v '{{ tailscaleVersion }}') - -# check to see if the state file has been backed up to Unraid Connect -if [ -d "/boot/.git" ]; then - if git --git-dir /boot/.git log --all --name-only --diff-filter=A -- config/plugins/tailscale/state/tailscaled.state | grep -q . ; then - echo "******************************" - echo "* WARNING *" - echo "******************************" - echo " " - echo "The Tailscale state file has been backed up to Unraid Connect via Flash backup." - echo " " - echo "To remove this backup, please perform the following steps:" - echo "1. Go to Settings -> Management Access". - echo "2. Under Unraid Connect, deactivate flash backup. Select the option to also delete cloud backup." - echo "3. Reactivate flash backup." - - /usr/local/emhttp/webGui/scripts/notify -l '/Settings/ManagementAccess' -i 'alert' -e 'Tailscale State' -s 'Tailscale state backed up to Unraid connect.' -d 'The Tailscale state file has been backed up to Unraid connect. This is a potential security risk. From the Management Settings page, deactivate flash backup and delete cloud backups, then reactivate flash backup.' - fi -fi - -echo "" -echo "----------------------------------------------------" -echo " {{ name }} has been installed." -echo " Version: {{ version }}" -echo "----------------------------------------------------" -echo "" \ No newline at end of file diff --git a/tools/plugin/files/remove.sh b/tools/plugin/files/remove.sh deleted file mode 100644 index fcb1bb1..0000000 --- a/tools/plugin/files/remove.sh +++ /dev/null @@ -1,11 +0,0 @@ -# Stop service -/etc/rc.d/rc.tailscale stop 2>/dev/null - -rm /usr/local/sbin/tailscale -rm /usr/local/sbin/tailscaled - -removepkg unraid-tailscale-utils - -rm -rf {{ pluginDirectory }} -rm -f {{ configDirectory }}/*.tgz -rm -f {{ configDirectory }}/*.txz \ No newline at end of file diff --git a/tools/plugin/tailscale.j2 b/tools/plugin/tailscale.j2 deleted file mode 100644 index b8a4390..0000000 --- a/tools/plugin/tailscale.j2 +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - -https://pkgs.tailscale.com/stable/{{ tailscaleVersion }}.tgz -{{ tailscaleSHA256 }} - - - -https://github.com/unraid/unraid-tailscale-utils/releases/download/{{ packageVersion }}/unraid-tailscale-utils-{{ packageVersion }}-noarch-1.txz -{{ packageSHA256 }} - - - - - - - - - - - - - - - - - diff --git a/tools/plugin/tailscale.json b/tools/plugin/tailscale.json deleted file mode 100644 index 452e7e0..0000000 --- a/tools/plugin/tailscale.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "tailscale", - "author": "Derek Kaser", - "githubRepository": "unraid/unraid-tailscale", - "version": "2025.07.07", - "tailscaleVersion": "tailscale_1.84.0_amd64", - "tailscaleSHA256": "c91eb43a92c209108bfaf1237696ac2089cc3d8fcf35d570d348cbfb19d8fb31", - "packageVersion": "4.1.0", - "packageSHA256": "97b01db93921e0b2ee58c7b47fa6246eff2c81c872f70043cf85bf33f6572a15", - "pluginDirectory": "/usr/local/emhttp/plugins/tailscale", - "configDirectory": "/boot/config/plugins/tailscale", - "minver": "7.0.0" -} \ No newline at end of file