├── .air.toml ├── .editorconfig ├── .github ├── actions │ ├── build │ │ └── action.yaml │ └── setup │ │ └── action.yaml └── workflows │ ├── ci.yaml │ └── e2e.yaml ├── .gitignore ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── cmd ├── cmd.go ├── connect.go ├── create.go ├── disconnect.go ├── init.go ├── status.go ├── stop.go ├── ui.go └── version.go ├── docs └── demo.gif ├── go.mod ├── go.sum ├── internal ├── assets │ └── spinner.svg ├── common.go └── views │ ├── components │ ├── footer.templ │ ├── footer_templ.go │ ├── header.templ │ ├── header_templ.go │ ├── title.templ │ └── title_templ.go │ ├── index.templ │ └── index_templ.go ├── main.go ├── renovate.json5 └── tailout ├── app.go ├── config └── config.go ├── connect.go ├── create.go ├── disconnect.go ├── init.go ├── status.go ├── stop.go └── ui.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | bin = "./tmp/tailout" 7 | cmd = "go mod tidy && templ generate && go build -o ./tmp/tailout ." 8 | delay = 1000 9 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 10 | exclude_file = [] 11 | exclude_regex = ["_test.go", ".*_templ\\.go$"] 12 | exclude_unchanged = false 13 | follow_symlink = false 14 | full_bin = "" 15 | include_dir = [] 16 | include_ext = ["go", "tpl", "tmpl", "html", "templ"] 17 | kill_delay = "0s" 18 | log = "build-errors.log" 19 | send_interrupt = false 20 | stop_on_error = true 21 | 22 | [color] 23 | app = "" 24 | build = "yellow" 25 | main = "magenta" 26 | runner = "green" 27 | watcher = "cyan" 28 | 29 | [log] 30 | time = false 31 | 32 | [misc] 33 | clean_on_exit = false 34 | 35 | [screen] 36 | clear_on_rebuild = false 37 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_size = 4 9 | indent_style = tab 10 | 11 | [*.{md,yml,yaml}] 12 | indent_size = 2 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /.github/actions/build/action.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | description: Build the go binary 3 | 4 | inputs: 5 | binary_name: 6 | description: The name of the binary to build 7 | required: true 8 | default: "tailout" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 14 | with: 15 | go-version: stable 16 | - name: Generate templ code 17 | uses: capthiron/templ-generator-action@f1f7b5d0b261eccff32fa3fc17cf563d993fee94 # v1 18 | with: 19 | commit: "false" 20 | setup-go: "false" 21 | directory: "internal/views" 22 | - name: Build ${{ inputs.binary_name }} 23 | run: go build -o bin/${{ inputs.binary_name }} 24 | shell: bash 25 | - name: Upload ${{ inputs.binary_name }} binary 26 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 27 | with: 28 | name: ${{ inputs.binary_name }} 29 | path: bin/${{ inputs.binary_name }} 30 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yaml: -------------------------------------------------------------------------------- 1 | name: setup 2 | description: Setup the environment for tailout 3 | 4 | inputs: 5 | binary_name: 6 | description: The name of the binary to build 7 | required: true 8 | default: "tailout" 9 | tailscale_oauth_client_id: 10 | description: The tailscale oauth client id 11 | required: true 12 | tailscale_oauth_client_secret: 13 | description: The tailscale oauth client secret 14 | required: true 15 | tailscale_version: 16 | description: The version of tailscale to use 17 | required: false 18 | default: "1.70.0" 19 | role_arn: 20 | description: The role to assume 21 | required: true 22 | region: 23 | description: The region to use 24 | required: true 25 | 26 | runs: 27 | using: "composite" 28 | steps: 29 | - name: Configure AWS credentials 30 | uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4 31 | with: 32 | role-to-assume: ${{ inputs.role_arn }} 33 | role-session-name: tailout-${{ github.job }} 34 | aws-region: ${{ inputs.region }} 35 | - name: Connect runner to tailscale 36 | uses: tailscale/github-action@6986d2c82a91fbac2949fe01f5bab95cf21b5102 # v3 37 | with: 38 | oauth-client-id: ${{ inputs.tailscale_oauth_client_id }} 39 | oauth-secret: ${{ inputs.tailscale_oauth_client_secret }} 40 | version: ${{ inputs.tailscale_version }} 41 | tags: tag:github-actions-runner 42 | args: --operator=runner 43 | - name: Download ${{ inputs.binary_name }} binary 44 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 45 | with: 46 | name: ${{ inputs.binary_name }} 47 | path: bin/ 48 | - name: Make ${{ inputs.binary_name }} executable 49 | run: chmod +x bin/${{ inputs.binary_name }} 50 | shell: bash 51 | - name: Move ${{ inputs.binary_name }} to /usr/local/bin 52 | run: sudo mv bin/${{ inputs.binary_name }} /usr/local/bin 53 | shell: bash 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "*" 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | env: 14 | REGISTRY: ghcr.io 15 | IMAGE_NAME: ${{ github.repository }} 16 | 17 | concurrency: 18 | group: ${{ github.ref_name }}-ci 19 | cancel-in-progress: true 20 | 21 | permissions: 22 | contents: read 23 | 24 | jobs: 25 | binary: 26 | runs-on: ubuntu-24.04 27 | permissions: 28 | contents: read 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Setup Golang Environment 36 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 37 | with: 38 | go-version: stable 39 | 40 | - name: Run GoReleaser 41 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 42 | with: 43 | version: latest 44 | args: ${{ github.ref_type == 'tag' && 'release' || 'build --snapshot' }} --clean 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | 48 | build-and-push-image: 49 | runs-on: ubuntu-24.04 50 | permissions: 51 | contents: read 52 | packages: write 53 | attestations: write 54 | id-token: write 55 | security-events: write 56 | services: 57 | registry: 58 | image: registry:3@sha256:1fc7de654f2ac1247f0b67e8a459e273b0993be7d2beda1f3f56fbf1001ed3e7 59 | ports: 60 | - 5000:5000 61 | needs: binary 62 | steps: 63 | - name: Checkout repository 64 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 65 | 66 | - name: Setup QEMU 67 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 68 | 69 | - name: Docker Buildx 70 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 71 | with: 72 | version: latest 73 | driver-opts: network=host 74 | 75 | - name: Login to GitHub Container Registry 76 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 77 | with: 78 | registry: ghcr.io 79 | username: ${{ github.repository_owner }} 80 | password: ${{ secrets.GITHUB_TOKEN }} 81 | if: github.event_name != 'pull_request' 82 | 83 | - name: Extract metadata (tags, labels) for Docker 84 | id: meta 85 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 86 | with: 87 | images: | 88 | name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},enable=${{ github.event_name != 'pull_request' }} 89 | name=localhost:5000/tailout/tailout-local 90 | tags: | 91 | type=edge 92 | type=ref,event=pr 93 | type=semver,pattern={{version}} 94 | 95 | - name: Build and push Docker image 96 | id: push 97 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 98 | with: 99 | context: . 100 | push: true 101 | pull: true 102 | platforms: linux/amd64,linux/arm64 103 | tags: ${{ steps.meta.outputs.tags }} 104 | labels: ${{ steps.meta.outputs.labels }} 105 | annotations: ${{ steps.meta.outputs.annotations }} 106 | cache-from: type=gha,scope=tailout 107 | cache-to: type=gha,scope=tailout,mode=max 108 | no-cache: ${{ github.event_name != 'pull_request' }} 109 | provenance: mode=max 110 | sbom: true 111 | 112 | - name: Inspect SBOM and output manifest 113 | run: | 114 | docker buildx imagetools inspect localhost:5000/tailout/tailout-local:${{ steps.meta.outputs.version }} --format '{{ json (index .SBOM "linux/amd64").SPDX }}' > sbom.json 115 | docker buildx imagetools inspect localhost:5000/tailout/tailout-local:${{ steps.meta.outputs.version }} --raw 116 | 117 | - name: Scan SBOM 118 | id: scan 119 | uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0 120 | with: 121 | sbom: "sbom.json" 122 | only-fixed: true 123 | add-cpes-if-none: true 124 | fail-build: false 125 | 126 | - name: Upload scan result to GitHub Security tab 127 | uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 128 | continue-on-error: true 129 | with: 130 | sarif_file: ${{ steps.scan.outputs.sarif }} 131 | 132 | - name: Generate artifact attestation 133 | uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 134 | with: 135 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 136 | subject-digest: ${{ steps.push.outputs.digest }} 137 | push-to-registry: true 138 | if: github.event_name != 'pull_request' 139 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | types: [opened, synchronize, reopened] 9 | 10 | env: 11 | BINARY_NAME: tailout 12 | # tailout environment variables 13 | TAILOUT_REGION: eu-west-3 14 | TAILOUT_NON_INTERACTIVE: "true" 15 | TAILOUT_CREATE_CONNECT: "true" 16 | TAILOUT_CREATE_SHUTDOWN: 5m 17 | TAILOUT_TAILSCALE_TAILNET: ${{ secrets.TAILSCALE_TAILNET }} 18 | TAILOUT_TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }} 19 | TAILOUT_TAILSCALE_AUTH_KEY: ${{ secrets.TAILSCALE_AUTH_KEY }} 20 | 21 | permissions: 22 | id-token: write # This is required for requesting the JWT 23 | contents: read # This is required for actions/checkout 24 | 25 | jobs: 26 | build: 27 | runs-on: ubuntu-latest 28 | if: "github.actor != 'renovate[bot]'" 29 | steps: 30 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 31 | - name: Build tailout 32 | uses: ./.github/actions/build 33 | with: 34 | binary_name: ${{ env.BINARY_NAME }} 35 | 36 | e2e-tests: 37 | runs-on: ubuntu-latest 38 | if: "github.actor != 'renovate[bot]'" 39 | needs: build 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | include: 44 | - first_command: "tailout init" 45 | - first_command: "tailout create" 46 | second_command: "tailout status" 47 | third_command: "tailout disconnect" 48 | - first_command: "tailout create" 49 | second_command: "tailout disconnect" 50 | third_command: "tailout status" 51 | steps: 52 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 53 | - name: Setup environment 54 | uses: ./.github/actions/setup 55 | with: 56 | region: ${{ env.TAILOUT_REGION }} 57 | role_arn: ${{ secrets.AWS_GITHUB_ACTIONS_TAILOUT_ROLE_ARN }} 58 | tailscale_oauth_client_id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} 59 | tailscale_oauth_client_secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }} 60 | binary_name: ${{ env.BINARY_NAME }} 61 | - name: First command 62 | run: ${{ matrix.first_command }} 63 | - name: Second command 64 | if: ${{ matrix.second_command != '' }} 65 | run: ${{ matrix.second_command }} 66 | - name: Third command 67 | if: ${{ matrix.third_command != '' }} 68 | run: ${{ matrix.third_command }} 69 | # TODO: check if the public IP address matches the one from the new instance 70 | 71 | # TODO: find a better concurrency pattern 72 | cleanup: 73 | runs-on: ubuntu-latest 74 | if: "github.actor != 'renovate[bot]'" 75 | needs: e2e-tests 76 | concurrency: 77 | group: cleanup 78 | cancel-in-progress: true 79 | steps: 80 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 81 | - name: Setup environment 82 | uses: ./.github/actions/setup 83 | with: 84 | region: ${{ env.TAILOUT_REGION }} 85 | role_arn: ${{ secrets.AWS_GITHUB_ACTIONS_TAILOUT_ROLE_ARN }} 86 | tailscale_oauth_client_id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} 87 | tailscale_oauth_client_secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }} 88 | binary_name: ${{ env.BINARY_NAME }} 89 | - name: Cleanup 90 | run: tailout stop --all 91 | # TODO: Add cleanup for github nodes as well 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work* 22 | 23 | .tailout.yaml 24 | config.yaml 25 | 26 | tmp/ 27 | bin/ 28 | 29 | # VS Code 30 | .vscode/ 31 | # GoReleaser 32 | dist/ 33 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - env: 4 | - CGO_ENABLED=0 5 | goos: 6 | - linux 7 | - darwin 8 | - windows 9 | binary: tailout 10 | 11 | archives: 12 | - format_overrides: 13 | - goos: windows 14 | formats: ['zip'] 15 | 16 | sboms: 17 | - artifacts: archive 18 | documents: 19 | - "${artifact}.spdx.json" 20 | 21 | checksum: 22 | name_template: "checksums.txt" 23 | 24 | snapshot: 25 | version_template: "{{ .Tag }}-next" 26 | 27 | changelog: 28 | sort: asc 29 | use: github 30 | groups: 31 | - title: Features 32 | regexp: "^.*(feat:|feat\\/|feat(\\([^\\)]*\\)):).*" 33 | order: 0 34 | - title: "Bug fixes" 35 | regexp: "^.*(fix:|fix\\/|fix(\\([^\\)]*\\)):).*" 36 | order: 1 37 | - title: Others 38 | order: 999 39 | filters: 40 | exclude: 41 | - "^docs" 42 | - "^test" 43 | - "^style" 44 | - "^refactor" 45 | - "^build" 46 | - "^ci" 47 | - "^chore(release)" 48 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - id: check-json 9 | - id: pretty-format-json 10 | args: [--autofix] 11 | - id: mixed-line-ending 12 | args: [--fix=lf] 13 | 14 | - repo: https://github.com/gitleaks/gitleaks 15 | rev: v8.27.0 16 | hooks: 17 | - id: gitleaks 18 | 19 | - repo: https://github.com/rhysd/actionlint 20 | rev: v1.7.7 21 | hooks: 22 | - id: actionlint 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.3@sha256:81bf5927dc91aefb42e2bc3a5abdbe9bb3bae8ba8b107e2a4cf43ce3402534c6 as fetch-stage 2 | 3 | COPY go.mod go.sum /app/ 4 | WORKDIR /app 5 | RUN go mod download 6 | 7 | FROM ghcr.io/a-h/templ:latest@sha256:15c983ea58261529d9d8305db528221044b772c5223fbe60ad396069b99751d1 AS generate-stage 8 | COPY --chown=65532:65532 . /app 9 | WORKDIR /app 10 | RUN ["templ", "generate"] 11 | 12 | FROM cosmtrek/air@sha256:394b581d0f3acb180aa9eaa93e8a52ac7d3638e28d667140a932b025eb8b95e2 as development 13 | COPY --from=generate-stage /ko-app/templ /bin/templ 14 | COPY --chown=65532:65532 . /app 15 | WORKDIR /app 16 | EXPOSE 3000 17 | ENTRYPOINT ["air"] 18 | 19 | FROM golang:1.24.3@sha256:81bf5927dc91aefb42e2bc3a5abdbe9bb3bae8ba8b107e2a4cf43ce3402534c6 AS build-stage 20 | COPY --from=generate-stage /app /app 21 | WORKDIR /app 22 | RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o /app/app 23 | 24 | FROM gcr.io/distroless/base-debian12@sha256:cef75d12148305c54ef5769e6511a5ac3c820f39bf5c8a4fbfd5b76b4b8da843 AS deploy-stage 25 | WORKDIR / 26 | COPY --from=build-stage /app/app /app 27 | EXPOSE 3000 28 | USER nonroot:nonroot 29 | ENTRYPOINT ["/app"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # Apache License 2 | 3 | _Version 2.0, January 2004_ 4 | _<>_ 5 | 6 | ### Terms and Conditions for use, reproduction, and distribution 7 | 8 | #### 1. Definitions 9 | 10 | “License” shall mean the terms and conditions for use, reproduction, and 11 | distribution as defined by Sections 1 through 9 of this document. 12 | 13 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 14 | owner that is granting the License. 15 | 16 | “Legal Entity” shall mean the union of the acting entity and all other entities 17 | that control, are controlled by, or are under common control with that entity. 18 | For the purposes of this definition, “control” means **(i)** the power, direct or 19 | indirect, to cause the direction or management of such entity, whether by 20 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 21 | outstanding shares, or **(iii)** beneficial ownership of such entity. 22 | 23 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 24 | permissions granted by this License. 25 | 26 | “Source” form shall mean the preferred form for making modifications, including 27 | but not limited to software source code, documentation source, and configuration 28 | files. 29 | 30 | “Object” form shall mean any form resulting from mechanical transformation or 31 | translation of a Source form, including but not limited to compiled object code, 32 | generated documentation, and conversions to other media types. 33 | 34 | “Work” shall mean the work of authorship, whether in Source or Object form, made 35 | available under the License, as indicated by a copyright notice that is included 36 | in or attached to the work (an example is provided in the Appendix below). 37 | 38 | “Derivative Works” shall mean any work, whether in Source or Object form, that 39 | is based on (or derived from) the Work and for which the editorial revisions, 40 | annotations, elaborations, or other modifications represent, as a whole, an 41 | original work of authorship. For the purposes of this License, Derivative Works 42 | shall not include works that remain separable from, or merely link (or bind by 43 | name) to the interfaces of, the Work and Derivative Works thereof. 44 | 45 | “Contribution” shall mean any work of authorship, including the original version 46 | of the Work and any modifications or additions to that Work or Derivative Works 47 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 48 | by the copyright owner or by an individual or Legal Entity authorized to submit 49 | on behalf of the copyright owner. For the purposes of this definition, 50 | “submitted” means any form of electronic, verbal, or written communication sent 51 | to the Licensor or its representatives, including but not limited to 52 | communication on electronic mailing lists, source code control systems, and 53 | issue tracking systems that are managed by, or on behalf of, the Licensor for 54 | the purpose of discussing and improving the Work, but excluding communication 55 | that is conspicuously marked or otherwise designated in writing by the copyright 56 | owner as “Not a Contribution.” 57 | 58 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 59 | of whom a Contribution has been received by Licensor and subsequently 60 | incorporated within the Work. 61 | 62 | #### 2. Grant of Copyright License 63 | 64 | Subject to the terms and conditions of this License, each Contributor hereby 65 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 66 | irrevocable copyright license to reproduce, prepare Derivative Works of, 67 | publicly display, publicly perform, sublicense, and distribute the Work and such 68 | Derivative Works in Source or Object form. 69 | 70 | #### 3. Grant of Patent License 71 | 72 | Subject to the terms and conditions of this License, each Contributor hereby 73 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 74 | irrevocable (except as stated in this section) patent license to make, have 75 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 76 | such license applies only to those patent claims licensable by such Contributor 77 | that are necessarily infringed by their Contribution(s) alone or by combination 78 | of their Contribution(s) with the Work to which such Contribution(s) was 79 | submitted. If You institute patent litigation against any entity (including a 80 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 81 | Contribution incorporated within the Work constitutes direct or contributory 82 | patent infringement, then any patent licenses granted to You under this License 83 | for that Work shall terminate as of the date such litigation is filed. 84 | 85 | #### 4. Redistribution 86 | 87 | You may reproduce and distribute copies of the Work or Derivative Works thereof 88 | in any medium, with or without modifications, and in Source or Object form, 89 | provided that You meet the following conditions: 90 | 91 | - **(a)** You must give any other recipients of the Work or Derivative Works a copy of 92 | this License; and 93 | - **(b)** You must cause any modified files to carry prominent notices stating that You 94 | changed the files; and 95 | - **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 96 | all copyright, patent, trademark, and attribution notices from the Source form 97 | of the Work, excluding those notices that do not pertain to any part of the 98 | Derivative Works; and 99 | - **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 100 | Derivative Works that You distribute must include a readable copy of the 101 | attribution notices contained within such NOTICE file, excluding those notices 102 | that do not pertain to any part of the Derivative Works, in at least one of the 103 | following places: within a NOTICE text file distributed as part of the 104 | Derivative Works; within the Source form or documentation, if provided along 105 | with the Derivative Works; or, within a display generated by the Derivative 106 | Works, if and wherever such third-party notices normally appear. The contents of 107 | the NOTICE file are for informational purposes only and do not modify the 108 | License. You may add Your own attribution notices within Derivative Works that 109 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 110 | provided that such additional attribution notices cannot be construed as 111 | modifying the License. 112 | 113 | You may add Your own copyright statement to Your modifications and may provide 114 | additional or different license terms and conditions for use, reproduction, or 115 | distribution of Your modifications, or for any such Derivative Works as a whole, 116 | provided Your use, reproduction, and distribution of the Work otherwise complies 117 | with the conditions stated in this License. 118 | 119 | #### 5. Submission of Contributions 120 | 121 | Unless You explicitly state otherwise, any Contribution intentionally submitted 122 | for inclusion in the Work by You to the Licensor shall be under the terms and 123 | conditions of this License, without any additional terms or conditions. 124 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 125 | any separate license agreement you may have executed with Licensor regarding 126 | such Contributions. 127 | 128 | #### 6. Trademarks 129 | 130 | This License does not grant permission to use the trade names, trademarks, 131 | service marks, or product names of the Licensor, except as required for 132 | reasonable and customary use in describing the origin of the Work and 133 | reproducing the content of the NOTICE file. 134 | 135 | #### 7. Disclaimer of Warranty 136 | 137 | Unless required by applicable law or agreed to in writing, Licensor provides the 138 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 139 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 140 | including, without limitation, any warranties or conditions of TITLE, 141 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 142 | solely responsible for determining the appropriateness of using or 143 | redistributing the Work and assume any risks associated with Your exercise of 144 | permissions under this License. 145 | 146 | #### 8. Limitation of Liability 147 | 148 | In no event and under no legal theory, whether in tort (including negligence), 149 | contract, or otherwise, unless required by applicable law (such as deliberate 150 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 151 | liable to You for damages, including any direct, indirect, special, incidental, 152 | or consequential damages of any character arising as a result of this License or 153 | out of the use or inability to use the Work (including but not limited to 154 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 155 | any and all other commercial damages or losses), even if such Contributor has 156 | been advised of the possibility of such damages. 157 | 158 | #### 9. Accepting Warranty or Additional Liability 159 | 160 | While redistributing the Work or Derivative Works thereof, You may choose to 161 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 162 | other liability obligations and/or rights consistent with this License. However, 163 | in accepting such obligations, You may act only on Your own behalf and on Your 164 | sole responsibility, not on behalf of any other Contributor, and only if You 165 | agree to indemnify, defend, and hold each Contributor harmless for any liability 166 | incurred by, or claims asserted against, such Contributor by reason of your 167 | accepting any such warranty or additional liability. 168 | 169 | _END OF TERMS AND CONDITIONS_ 170 | 171 | ### APPENDIX: How to apply the Apache License to your work 172 | 173 | To apply the Apache License to your work, attach the following boilerplate 174 | notice, with the fields enclosed by brackets `[]` replaced with your own 175 | identifying information. (Don't include the brackets!) The text should be 176 | enclosed in the appropriate comment syntax for the file format. We also 177 | recommend that a file or class name and description of purpose be included on 178 | the same “printed page” as the copyright notice for easier identification within 179 | third-party archives. 180 | 181 | Copyright 2023 Térence Chateigné 182 | 183 | Licensed under the Apache License, Version 2.0 (the "License"); 184 | you may not use this file except in compliance with the License. 185 | You may obtain a copy of the License at 186 | 187 | http://www.apache.org/licenses/LICENSE-2.0 188 | 189 | Unless required by applicable law or agreed to in writing, software 190 | distributed under the License is distributed on an "AS IS" BASIS, 191 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 192 | See the License for the specific language governing permissions and 193 | limitations under the License. 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/cterence/tailout/actions/workflows/ci.yaml/badge.svg)](https://github.com/cterence/tailout/actions/workflows/ci.yaml) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/cterence/tailout)](https://goreportcard.com/report/github.com/cterence/tailout) 3 | 4 | # tailout 5 | 6 | `tailout` is a command-line tool for quickly creating a cloud-based exit node in your tailnet. 7 | 8 | ![demo gif](./docs/demo.gif) 9 | 10 | ## Installation 11 | 12 | To install `tailout`, you can download the latest release from the [releases page](https://github.com/cterence/tailout/releases). 13 | 14 | You can also use the `go install` command: 15 | 16 | ```bash 17 | go install github.com/cterence/tailout@latest 18 | ``` 19 | 20 | ## Prerequisites 21 | 22 | To use `tailout`, you'll need to have the following installed: 23 | 24 | - [Tailscale](https://tailscale.com/) 25 | - An AWS account 26 | 27 | At the moment, `tailout` only supports AWS as a cloud provider. Support for other cloud providers will be added in the future. 28 | 29 | ## Setup 30 | 31 | Go to your [Tailscale API key settings](https://login.tailscale.com/admin/settings/keys) and: 32 | 33 | - Create an API key for `tailout` 34 | - Create a file in `~/.tailout/config.yaml` with the following content: 35 | ```yaml 36 | tailscale: 37 | api_key: tskey-api-xxx-xxx 38 | tailnet: 39 | ``` 40 | - Run `tailout init`, review the changes that will be done to your policy and accept 41 | - Go back to your [Tailscale API key settings](https://login.tailscale.com/admin/settings/keys) 42 | - Create an auth key with the following characteristics: 43 | - Is reusable 44 | - Is ephemeral 45 | - Automatically adds the newly created `tag:tailout` tag to each device 46 | - Add your auth key to the config file 47 | 48 | Next, you will also need to set up your AWS credentials. tailout will look for default credentials, like environment variables for access keys or an AWS profile. 49 | 50 | To easily check if your credentials are set up correctly, you can use the `aws sts get-caller-identity` command. 51 | 52 | ## Usage 53 | 54 | Create an exit node in your tailnet: 55 | 56 | ```bash 57 | tailout create 58 | ``` 59 | 60 | Connect to your exit node: 61 | 62 | ```bash 63 | tailout connect 64 | ``` 65 | 66 | Get the status of your exit node: 67 | 68 | ```bash 69 | tailout status 70 | ``` 71 | 72 | Disconnect from your exit node: 73 | 74 | ```bash 75 | tailout disconnect 76 | ``` 77 | 78 | Delete your exit node: 79 | 80 | ```bash 81 | tailout stop 82 | ``` 83 | 84 | ## Configuration 85 | 86 | `tailout` will look for a configuration file at the following paths: 87 | 88 | - `/etc/tailout/config.{yml,yaml,hcl,json,toml}` 89 | - `$HOME/.tailout/config.{yml,yaml,hcl,json,toml}` 90 | 91 | For exemple, you could have this content in `/etc/tailout/config.yml`: 92 | 93 | ```yaml 94 | tailscale: 95 | api_key: tskey-api-xxx-xxx 96 | auth_key: tskey-auth-xxx-xxx 97 | tailnet: 98 | region: eu-west-3 99 | create: 100 | shutdown: 15m 101 | ``` 102 | 103 | You can specify any of the above settings as command-line flags or environment variables prefixed by `TAILOUT_`. 104 | 105 | For example, to specify the Tailscale API key, you can use the `--tailscale-api-key` flag or the `TAILOUT_TAILSCALE_API_KEY` environment variable. 106 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/cterence/tailout/tailout" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func New(app *tailout.App) *cobra.Command { 9 | return buildTailoutCommand(app) 10 | } 11 | 12 | func buildTailoutCommand(app *tailout.App) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "tailout", 15 | Short: "Quickly create a cloud-based exit node in your tailnet", 16 | SilenceUsage: true, 17 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 18 | return app.Config.Load(cmd.Flags(), cmd.Name()) 19 | }, 20 | } 21 | 22 | cmd.AddCommand(buildCreateCommand(app)) 23 | cmd.AddCommand(buildDisconnectCommand(app)) 24 | cmd.AddCommand(buildConnectCommand(app)) 25 | cmd.AddCommand(buildInitCommand(app)) 26 | cmd.AddCommand(buildStatusCommand(app)) 27 | cmd.AddCommand(buildStopCommand(app)) 28 | cmd.AddCommand(buildUiCommand(app)) 29 | cmd.AddCommand(buildVersionCommand()) 30 | 31 | return cmd 32 | } 33 | -------------------------------------------------------------------------------- /cmd/connect.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cterence/tailout/tailout" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // connectCmd represents the connect command. 11 | func buildConnectCommand(app *tailout.App) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Args: cobra.ArbitraryArgs, 14 | Use: "connect", 15 | Short: "Connect to an exit node in your tailnet", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | err := app.Connect(args) 18 | if err != nil { 19 | return fmt.Errorf("failed to connect: %w", err) 20 | } 21 | return nil 22 | }, 23 | } 24 | 25 | cmd.PersistentFlags().BoolVarP(&app.Config.NonInteractive, "non-interactive", "n", false, "Disable interactive prompts") 26 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.APIKey, "tailscale-api-key", "", "Tailscale API key used to perform operations on your tailnet") 27 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.Tailnet, "tailscale-tailnet", "", "Tailscale Tailnet to use for operations") 28 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.BaseURL, "tailscale-base-url", "https://api.tailscale.com", "Tailscale base API URL, change this if you are using Headscale") 29 | 30 | return cmd 31 | } 32 | -------------------------------------------------------------------------------- /cmd/create.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cterence/tailout/tailout" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // createCmd represents the create command. 11 | func buildCreateCommand(app *tailout.App) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Args: cobra.NoArgs, 14 | Use: "create", 15 | Short: "Create an exit node in your tailnet", 16 | Long: `Create an exit node in your tailnet. 17 | 18 | This command will create an EC2 instance in the targeted region with the following configuration: 19 | - Amazon Linux 2 AMI 20 | - t3a.micro instance type 21 | - Tailscale installed and configured to advertise as an exit node 22 | - SSH access enabled 23 | - Tagged with App=tailout 24 | - The instance will be created as a spot instance in the default VPC`, 25 | 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | err := app.Create() 28 | if err != nil { 29 | return fmt.Errorf("failed to create exit node: %w", err) 30 | } 31 | return nil 32 | }, 33 | } 34 | 35 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.APIKey, "tailscale-api-key", "", "Tailscale API key used to perform operations on your tailnet") 36 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.Tailnet, "tailscale-tailnet", "", "Tailscale Tailnet to use for operations") 37 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.AuthKey, "tailscale-auth-key", "", "Tailscale Auth Key to use for operations") 38 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.BaseURL, "tailscale-base-url", "https://api.tailscale.com", "Tailscale base API URL, change this if you are using Headscale") 39 | cmd.PersistentFlags().BoolVarP(&app.Config.DryRun, "dry-run", "d", false, "Dry run mode (no changes will be made)") 40 | cmd.PersistentFlags().BoolVarP(&app.Config.NonInteractive, "non-interactive", "n", false, "Disable interactive prompts") 41 | cmd.PersistentFlags().StringVarP(&app.Config.Region, "region", "r", "", "Cloud-provider region to use") 42 | 43 | cmd.PersistentFlags().StringVarP(&app.Config.Create.Shutdown, "shutdown", "s", "2h", "Shutdown the instance after the specified duration. Valid time units are \"s\", \"m\", \"h\"") 44 | cmd.PersistentFlags().BoolVarP(&app.Config.Create.Connect, "connect", "c", false, "Connect to the instance after creation") 45 | 46 | return cmd 47 | } 48 | -------------------------------------------------------------------------------- /cmd/disconnect.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cterence/tailout/tailout" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // disconnectCmd represents the disconnect command. 11 | func buildDisconnectCommand(app *tailout.App) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Args: cobra.NoArgs, 14 | Use: "disconnect", 15 | Short: "Disconnect from an exit node in your tailnet", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | err := app.Disconnect() 18 | if err != nil { 19 | return fmt.Errorf("failed to disconnect: %w", err) 20 | } 21 | return nil 22 | }, 23 | } 24 | 25 | cmd.PersistentFlags().BoolVarP(&app.Config.NonInteractive, "non-interactive", "n", false, "Disable interactive prompts") 26 | 27 | return cmd 28 | } 29 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cterence/tailout/tailout" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func buildInitCommand(app *tailout.App) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Args: cobra.NoArgs, 13 | Use: "init", 14 | Short: "Initialize tailnet policy for tailout", 15 | Long: `Initialize tailnet policy for tailout. 16 | 17 | This command will update your tailnet policy by: 18 | - adding a new tag 'tag:tailout', 19 | - adding exit nodes tagged with 'tag:tailout to auto approvers', 20 | - allowing your tailnet devices to SSH into tailout nodes.`, 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | err := app.Init() 23 | if err != nil { 24 | return fmt.Errorf("failed to initialize tailnet policy: %w", err) 25 | } 26 | return nil 27 | }, 28 | } 29 | 30 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.APIKey, "tailscale-api-key", "", "Tailscale API key used to perform operations on your tailnet") 31 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.Tailnet, "tailscale-tailnet", "", "Tailscale Tailnet to use for operations") 32 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.BaseURL, "tailscale-base-url", "https://api.tailscale.com", "Tailscale base API URL, change this if you are using Headscale") 33 | cmd.PersistentFlags().BoolVarP(&app.Config.NonInteractive, "non-interactive", "n", false, "Disable interactive prompts") 34 | cmd.PersistentFlags().BoolVarP(&app.Config.DryRun, "dry-run", "d", false, "Dry run mode (no changes will be made)") 35 | 36 | return cmd 37 | } 38 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cterence/tailout/tailout" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func buildStatusCommand(app *tailout.App) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Args: cobra.NoArgs, 13 | Use: "status", 14 | Short: "Show tailout-related informations", 15 | Long: `Show tailout-related informations. 16 | 17 | This command will show the status of tailout nodes, including the node name and whether it is connected or not. 18 | 19 | Example : tailout status`, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | err := app.Status() 22 | if err != nil { 23 | return fmt.Errorf("failed to show status: %w", err) 24 | } 25 | return nil 26 | }, 27 | } 28 | 29 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.APIKey, "tailscale-api-key", "", "Tailscale API key used to perform operations on your tailnet") 30 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.Tailnet, "tailscale-tailnet", "", "Tailscale Tailnet to use for operations") 31 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.BaseURL, "tailscale-base-url", "https://api.tailscale.com", "Tailscale base API URL, change this if you are using Headscale") 32 | 33 | return cmd 34 | } 35 | -------------------------------------------------------------------------------- /cmd/stop.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cterence/tailout/tailout" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func buildStopCommand(app *tailout.App) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "stop [node names...]", 13 | Args: cobra.ArbitraryArgs, 14 | Short: "Terminates instances created by tailout", 15 | Long: `By default, terminates all instances created by tailout. 16 | 17 | If one or more Node names are specified, only those instances will be terminated. 18 | 19 | Example : tailout stop tailout-eu-west-3-i-048afd4880f66c596`, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | err := app.Stop(args) 22 | if err != nil { 23 | return fmt.Errorf("failed to stop instances: %w", err) 24 | } 25 | return nil 26 | }, 27 | } 28 | 29 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.APIKey, "tailscale-api-key", "", "Tailscale API key used to perform operations on your tailnet") 30 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.Tailnet, "tailscale-tailnet", "", "Tailscale Tailnet to use for operations") 31 | cmd.PersistentFlags().StringVar(&app.Config.Tailscale.BaseURL, "tailscale-base-url", "https://api.tailscale.com", "Tailscale base API URL, change this if you are using Headscale") 32 | cmd.PersistentFlags().BoolVarP(&app.Config.NonInteractive, "non-interactive", "n", false, "Disable interactive prompts") 33 | cmd.PersistentFlags().BoolVarP(&app.Config.DryRun, "dry-run", "d", false, "Dry run mode (no changes will be made)") 34 | cmd.PersistentFlags().BoolVarP(&app.Config.Stop.All, "all", "a", false, "Terminate all instances created by tailout") 35 | 36 | return cmd 37 | } 38 | -------------------------------------------------------------------------------- /cmd/ui.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cterence/tailout/tailout" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // uiCmd represents the UI command. 11 | func buildUiCommand(app *tailout.App) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Args: cobra.ArbitraryArgs, 14 | Use: "ui", 15 | Short: "Start the Tailout UI", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | err := app.UI(args) 18 | if err != nil { 19 | return fmt.Errorf("failed to start UI: %w", err) 20 | } 21 | return nil 22 | }, 23 | } 24 | 25 | cmd.PersistentFlags().BoolVarP(&app.Config.NonInteractive, "non-interactive", "n", false, "Disable interactive prompts") 26 | cmd.PersistentFlags().StringVarP(&app.Config.UI.Address, "address", "a", "127.0.0.1", "Address to bind the UI to") 27 | cmd.PersistentFlags().StringVarP(&app.Config.UI.Port, "port", "p", "3000", "Port to bind the UI to") 28 | 29 | return cmd 30 | } 31 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "runtime/debug" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func buildVersionString(buildInfo *debug.BuildInfo) string { 12 | var revision string 13 | 14 | for _, kv := range buildInfo.Settings { 15 | switch kv.Key { 16 | case "vcs.revision": 17 | revision = kv.Value[max(0, len(kv.Value)-7):] 18 | } 19 | } 20 | 21 | if revision == "" { 22 | revision = "unknown" 23 | } 24 | 25 | return fmt.Sprintf("%s (%s)", buildInfo.Main.Version, revision) 26 | } 27 | 28 | func buildVersionCommand() *cobra.Command { 29 | cmd := &cobra.Command{ 30 | Args: cobra.ArbitraryArgs, 31 | Use: "version", 32 | Short: "Print the Tailout version", 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | buildInfo, ok := debug.ReadBuildInfo() 35 | if !ok { 36 | return errors.New("unable to ReadBuildInfo(), which shouldn't happen, as Tailout should be built with module support") 37 | } 38 | _, err := fmt.Printf("Tailout version %s\n", buildVersionString(buildInfo)) 39 | if err != nil { 40 | return fmt.Errorf("failed to print version: %w", err) 41 | } 42 | return nil 43 | }, 44 | } 45 | 46 | return cmd 47 | } 48 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cterence/tailout/841b9b327b3cabe80d52858af10c0c3f817d1adf/docs/demo.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cterence/tailout 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/a-h/templ v0.3.894 7 | github.com/adhocore/chin v1.1.0 8 | github.com/aws/aws-sdk-go-v2 v1.36.3 9 | github.com/aws/aws-sdk-go-v2/config v1.29.14 10 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.224.0 11 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 12 | github.com/ktr0731/go-fuzzyfinder v0.9.0 13 | github.com/manifoldco/promptui v0.9.0 14 | github.com/spf13/cobra v1.9.1 15 | github.com/spf13/pflag v1.0.6 16 | github.com/spf13/viper v1.20.1 17 | tailscale.com v1.84.1 18 | tailscale.com/client/tailscale/v2 v2.0.0-20250602205246-d51fc603f5ea 19 | ) 20 | 21 | require ( 22 | filippo.io/edwards25519 v1.1.0 // indirect 23 | github.com/akutz/memconn v0.1.0 // indirect 24 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 25 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect 26 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 27 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 28 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 29 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 34 | github.com/aws/smithy-go v1.22.2 // indirect 35 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 36 | github.com/coder/websocket v1.8.12 // indirect 37 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 38 | github.com/fsnotify/fsnotify v1.8.0 // indirect 39 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 40 | github.com/gdamore/encoding v1.0.1 // indirect 41 | github.com/gdamore/tcell/v2 v2.6.0 // indirect 42 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect 43 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 44 | github.com/google/go-cmp v0.7.0 // indirect 45 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 46 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 47 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 48 | github.com/ktr0731/go-ansisgr v0.1.0 // indirect 49 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 50 | github.com/mattn/go-runewidth v0.0.16 // indirect 51 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 52 | github.com/mdlayher/socket v0.5.0 // indirect 53 | github.com/mitchellh/go-ps v1.0.0 // indirect 54 | github.com/nsf/termbox-go v1.1.1 // indirect 55 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 56 | github.com/pkg/errors v0.9.1 // indirect 57 | github.com/rivo/uniseg v0.4.7 // indirect 58 | github.com/sagikazarmark/locafero v0.7.0 // indirect 59 | github.com/sourcegraph/conc v0.3.0 // indirect 60 | github.com/spf13/afero v1.12.0 // indirect 61 | github.com/spf13/cast v1.7.1 // indirect 62 | github.com/subosito/gotenv v1.6.0 // indirect 63 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 64 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 65 | github.com/x448/float16 v0.8.4 // indirect 66 | go.uber.org/multierr v1.11.0 // indirect 67 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 68 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 69 | golang.org/x/crypto v0.37.0 // indirect 70 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect 71 | golang.org/x/net v0.39.0 // indirect 72 | golang.org/x/oauth2 v0.26.0 // indirect 73 | golang.org/x/sync v0.13.0 // indirect 74 | golang.org/x/sys v0.32.0 // indirect 75 | golang.org/x/term v0.31.0 // indirect 76 | golang.org/x/text v0.24.0 // indirect 77 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 78 | gopkg.in/yaml.v3 v3.0.1 // indirect 79 | ) 80 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/a-h/templ v0.3.894 h1:umG2i6ypJXtm9CrOZxwlYBXgDnpcUnMl6IaHpXGXiGE= 4 | github.com/a-h/templ v0.3.894/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ= 5 | github.com/adhocore/chin v1.1.0 h1:RuBkSBhtGpW2l6y9d/YSj7ZJSQINJgjodoD0OB1coAo= 6 | github.com/adhocore/chin v1.1.0/go.mod h1:X6ey2uVyRozOnqd/sFb/k0pxY1tnm+Msigh1m2jlfbg= 7 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 8 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 9 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 10 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 11 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 12 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 13 | github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= 14 | github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= 15 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= 16 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 17 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= 18 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= 19 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= 20 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= 21 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= 22 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= 23 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 24 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 25 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.224.0 h1:i7FB/N5pSvEzNOGHm7n6KQiBx2/X8UkrE/Ppb5Bh3QQ= 26 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.224.0/go.mod h1:ouvGEfHbLaIlWwpDpOVWPWR+YwO0HDv3vm5tYLq8ImY= 27 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= 31 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= 32 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= 33 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= 34 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= 35 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= 36 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 37 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 38 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 39 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 40 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 41 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 42 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 43 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 44 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 45 | github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= 46 | github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= 47 | github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 48 | github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 49 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 50 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 51 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 52 | github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= 53 | github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= 54 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 55 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 56 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= 57 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 58 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 59 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 60 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 61 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 62 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 63 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 64 | github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= 65 | github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= 66 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 67 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 68 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 69 | github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= 70 | github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= 71 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= 72 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 73 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 74 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 75 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 76 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 77 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 78 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 79 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 80 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 81 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 82 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 83 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 84 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 85 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 86 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 87 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 88 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 89 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 90 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 91 | github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= 92 | github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= 93 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 94 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 95 | github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= 96 | github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 97 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 98 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 99 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 100 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 101 | github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= 102 | github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= 103 | github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipzXQH7L4c= 104 | github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8= 105 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 106 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 107 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 108 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 109 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 110 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 111 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 112 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 113 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 114 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 115 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 116 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= 117 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 118 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 119 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 120 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 121 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 122 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 123 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 124 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 125 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 126 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 127 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 128 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 129 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 130 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 131 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 132 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 133 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 134 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 135 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 136 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 137 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 138 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 139 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 140 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 141 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 142 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 143 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 144 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 145 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 146 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 147 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 148 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 149 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 150 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 151 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 152 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 153 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 154 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 155 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 156 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= 157 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 158 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 159 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 160 | github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U= 161 | github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 162 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 163 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 164 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 165 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 166 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 167 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 168 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 169 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 170 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 171 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 172 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 173 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 174 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 175 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 176 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 177 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= 178 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= 179 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 180 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 181 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 182 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 183 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 184 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 185 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 186 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 187 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 188 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 189 | golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= 190 | golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 191 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 192 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 193 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 194 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 195 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 196 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 197 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 198 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 199 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 200 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 201 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 202 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 203 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 204 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 205 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 206 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 207 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 208 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 209 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 210 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 211 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 212 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 213 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 214 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 215 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 216 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 217 | golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= 218 | golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 219 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 220 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 221 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 222 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 223 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 224 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 225 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 226 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 227 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 228 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 229 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 230 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 231 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 232 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 233 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= 234 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= 235 | tailscale.com v1.84.1 h1:xtuiYeAIUR+dRztPzzqUsjj+Fv/06vz28zoFaP1k/Os= 236 | tailscale.com v1.84.1/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= 237 | tailscale.com/client/tailscale/v2 v2.0.0-20250602205246-d51fc603f5ea h1:lXgaPz+scY0fqkoXfy6TpX9lpP4+dRa3Sv+YHQujFOk= 238 | tailscale.com/client/tailscale/v2 v2.0.0-20250602205246-d51fc603f5ea/go.mod h1:nzqx3Hs59z2W8Gnmq2ChavPButcyvtxAxRpNc+ZVy7s= 239 | -------------------------------------------------------------------------------- /internal/assets/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /internal/common.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "sort" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/aws/aws-sdk-go-v2/service/ec2" 12 | "github.com/manifoldco/promptui" 13 | tsapi "tailscale.com/client/tailscale/v2" 14 | ) 15 | 16 | func GetRegions() ([]string, error) { 17 | cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion("us-east-1")) 18 | if err != nil { 19 | log.Fatalf("unable to load SDK config, %v", err) 20 | } 21 | ec2Svc := ec2.NewFromConfig(cfg) 22 | 23 | regions, err := ec2Svc.DescribeRegions(context.TODO(), &ec2.DescribeRegionsInput{}) 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to describe regions: %w", err) 26 | } 27 | 28 | regionNames := []string{} 29 | for _, region := range regions.Regions { 30 | regionNames = append(regionNames, *region.RegionName) 31 | } 32 | 33 | return regionNames, nil 34 | } 35 | 36 | // Function that uses promptui to return an AWS region fetched from the aws sdk. 37 | func SelectRegion() (string, error) { 38 | regionNames, err := GetRegions() 39 | if err != nil { 40 | return "", fmt.Errorf("failed to retrieve regions: %w", err) 41 | } 42 | 43 | sort.Slice(regionNames, func(i, j int) bool { 44 | return regionNames[i] < regionNames[j] 45 | }) 46 | 47 | // Prompt for region with promptui displaying 15 regions at a time, sorted alphabetically and searchable 48 | prompt := promptui.Select{ 49 | Label: "Select AWS region", 50 | Items: regionNames, 51 | } 52 | 53 | _, region, err := prompt.Run() 54 | if err != nil { 55 | return "", fmt.Errorf("failed to select region: %w", err) 56 | } 57 | 58 | return region, nil 59 | } 60 | 61 | // Function that uses promptui to return a boolean value. 62 | func PromptYesNo(question string) (bool, error) { 63 | prompt := promptui.Select{ 64 | Label: question, 65 | Items: []string{"Yes", "No"}, 66 | } 67 | 68 | _, result, err := prompt.Run() 69 | if err != nil { 70 | return false, fmt.Errorf("failed to prompt for yes/no: %w", err) 71 | } 72 | 73 | if result == "Yes" { 74 | return true, nil 75 | } 76 | 77 | return false, nil 78 | } 79 | 80 | func GetActiveNodes(c *tsapi.Client) ([]tsapi.Device, error) { 81 | devices, err := c.Devices().List(context.TODO()) 82 | if err != nil { 83 | return nil, fmt.Errorf("failed to get devices: %w", err) 84 | } 85 | 86 | tailoutDevices := make([]tsapi.Device, 0) 87 | for _, device := range devices { 88 | for _, tag := range device.Tags { 89 | if tag == "tag:tailout" { 90 | if time.Duration(device.LastSeen.Minute()) < 10*time.Minute { 91 | tailoutDevices = append(tailoutDevices, device) 92 | } 93 | } 94 | } 95 | } 96 | 97 | return tailoutDevices, nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/views/components/footer.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ Footer() { 4 | 11 | } 12 | -------------------------------------------------------------------------------- /internal/views/components/footer_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.833 4 | package components 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func Footer() templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | return nil 37 | }) 38 | } 39 | 40 | var _ = templruntime.GeneratedTemplate 41 | -------------------------------------------------------------------------------- /internal/views/components/header.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ Header() { 4 | 5 | 6 | 7 | 8 | 9 | tailout. 10 | 11 | } 12 | -------------------------------------------------------------------------------- /internal/views/components/header_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.833 4 | package components 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func Header() templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "tailout.") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | return nil 37 | }) 38 | } 39 | 40 | var _ = templruntime.GeneratedTemplate 41 | -------------------------------------------------------------------------------- /internal/views/components/title.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ Title() { 4 | tailout. 5 | } 6 | -------------------------------------------------------------------------------- /internal/views/components/title_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.833 4 | package components 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func Title() templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "tailout.") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | return nil 37 | }) 38 | } 39 | 40 | var _ = templruntime.GeneratedTemplate 41 | -------------------------------------------------------------------------------- /internal/views/index.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/cterence/tailout/internal/views/components" 4 | 5 | templ Index() { 6 | 7 | 8 | @components.Header() 9 | 10 |
11 | @components.Title() 12 |

create an exit node in your tailnet in seconds.

13 |
14 | 18 | 21 |
22 | // Table of exit nodes 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
HostnameAddressLast seen
34 |
35 |
36 | @components.Footer() 37 | 38 | 55 | 56 | } 57 | -------------------------------------------------------------------------------- /internal/views/index_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.833 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import "github.com/cterence/tailout/internal/views/components" 12 | 13 | func Index() templ.Component { 14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 16 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 17 | return templ_7745c5c3_CtxErr 18 | } 19 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 20 | if !templ_7745c5c3_IsBuffer { 21 | defer func() { 22 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 23 | if templ_7745c5c3_Err == nil { 24 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 25 | } 26 | }() 27 | } 28 | ctx = templ.InitializeContext(ctx) 29 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 30 | if templ_7745c5c3_Var1 == nil { 31 | templ_7745c5c3_Var1 = templ.NopComponent 32 | } 33 | ctx = templ.ClearChildren(ctx) 34 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 35 | if templ_7745c5c3_Err != nil { 36 | return templ_7745c5c3_Err 37 | } 38 | templ_7745c5c3_Err = components.Header().Render(ctx, templ_7745c5c3_Buffer) 39 | if templ_7745c5c3_Err != nil { 40 | return templ_7745c5c3_Err 41 | } 42 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") 43 | if templ_7745c5c3_Err != nil { 44 | return templ_7745c5c3_Err 45 | } 46 | templ_7745c5c3_Err = components.Title().Render(ctx, templ_7745c5c3_Buffer) 47 | if templ_7745c5c3_Err != nil { 48 | return templ_7745c5c3_Err 49 | } 50 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

create an exit node in your tailnet in seconds.

HostnameAddressLast seen
") 51 | if templ_7745c5c3_Err != nil { 52 | return templ_7745c5c3_Err 53 | } 54 | templ_7745c5c3_Err = components.Footer().Render(ctx, templ_7745c5c3_Buffer) 55 | if templ_7745c5c3_Err != nil { 56 | return templ_7745c5c3_Err 57 | } 58 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") 59 | if templ_7745c5c3_Err != nil { 60 | return templ_7745c5c3_Err 61 | } 62 | return nil 63 | }) 64 | } 65 | 66 | var _ = templruntime.GeneratedTemplate 67 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/cterence/tailout/cmd" 8 | "github.com/cterence/tailout/tailout" 9 | ) 10 | 11 | func main() { 12 | app, err := tailout.New() 13 | if err != nil { 14 | fmt.Fprintf(os.Stderr, "error: %s\n", err) 15 | os.Exit(1) 16 | } 17 | 18 | if err := cmd.New(app).Execute(); err != nil { 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | automerge: true, 4 | automergeStrategy: "squash", 5 | commitBodyTable: true, 6 | configMigration: true, 7 | extends: [ 8 | "config:best-practices", 9 | "docker:enableMajor", 10 | "helpers:pinGitHubActionDigests", 11 | ":gitSignOff", 12 | ":automergeStableNonMajor", 13 | ":automergeDigest", 14 | ":semanticCommitsDisabled", 15 | ":skipStatusChecks", 16 | ], 17 | postUpdateOptions: [ 18 | "gomodTidy", 19 | "gomodUpdateImportPaths" 20 | ], 21 | "pre-commit": { 22 | enabled: true 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /tailout/app.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/cterence/tailout/tailout/config" 8 | ) 9 | 10 | type App struct { 11 | Config *config.Config 12 | 13 | Out io.Writer 14 | Err io.Writer 15 | } 16 | 17 | func New() (*App, error) { 18 | c := &config.Config{} 19 | app := &App{ 20 | Config: c, 21 | Out: os.Stdout, 22 | Err: os.Stderr, 23 | } 24 | return app, nil 25 | } 26 | -------------------------------------------------------------------------------- /tailout/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/spf13/pflag" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | type Config struct { 14 | Tailscale TailscaleConfig `mapstructure:"tailscale"` 15 | UI UIConfig `mapstructure:"ui"` 16 | Region string `mapstructure:"region"` 17 | Create CreateConfig `mapstructure:"create"` 18 | NonInteractive bool `mapstructure:"non_interactive"` 19 | DryRun bool `mapstructure:"dry_run"` 20 | Stop StopConfig `mapstructure:"stop"` 21 | } 22 | 23 | type CreateConfig struct { 24 | Shutdown string `mapstructure:"shutdown"` 25 | Connect bool `mapstructure:"connect"` 26 | } 27 | type TailscaleConfig struct { 28 | BaseURL string `mapstructure:"base_url"` 29 | AuthKey string `mapstructure:"auth_key"` 30 | APIKey string `mapstructure:"api_key"` 31 | Tailnet string `mapstructure:"tailnet"` 32 | } 33 | 34 | type StopConfig struct { 35 | All bool `mapstructure:"all"` 36 | } 37 | 38 | type UIConfig struct { 39 | Port string `mapstructure:"port"` 40 | Address string `mapstructure:"address"` 41 | } 42 | 43 | func (c *Config) Load(flags *pflag.FlagSet, cmdName string) error { 44 | v := viper.New() 45 | 46 | // Tailout looks for configuration files called config.yaml, config.json, 47 | // config.toml, config.hcl, etc. 48 | v.SetConfigName("config") 49 | 50 | // Tailout looks for configuration files in the common configuration 51 | // directories. 52 | v.AddConfigPath("/etc/tailout/") 53 | v.AddConfigPath("$HOME/.tailout/") 54 | v.AddConfigPath(".") 55 | 56 | err := v.ReadInConfig() 57 | if err != nil { 58 | var configFileNotFound viper.ConfigFileNotFoundError 59 | if !errors.As(err, &configFileNotFound) { 60 | return fmt.Errorf("failed to read configuration file: %w", err) 61 | } 62 | } 63 | 64 | // Tailout can be configured with environment variables that start with 65 | // TAILOUT_. 66 | v.SetEnvPrefix("tailout") 67 | v.AutomaticEnv() 68 | 69 | // Options with dashes in flag names have underscores when set inside a 70 | // configuration file or with environment variables. 71 | flags.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName { 72 | name = strings.ReplaceAll(name, "-", "_") 73 | return pflag.NormalizedName(name) 74 | }) 75 | 76 | // Nested configuration options set with environment variables use an 77 | // underscore as a separator. 78 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 79 | bindEnvironmentVariables(v, *c) 80 | 81 | if err := v.BindPFlags(flags); err != nil { 82 | return fmt.Errorf("failed to bind flags: %w", err) 83 | } 84 | 85 | // Bind tailscale and command specific nested flags and remove prefix when binding 86 | // FIXME: This is a workaround for a limitation of Viper, found here: 87 | // https://github.com/spf13/viper/issues/1072 88 | var bindErr error 89 | flags.Visit(func(f *pflag.Flag) { 90 | if bindErr != nil { 91 | return 92 | } 93 | flagName := strings.ReplaceAll(f.Name, "-", "_") 94 | if err := v.BindPFlag(cmdName+"."+f.Name, flags.Lookup(f.Name)); err != nil { 95 | bindErr = fmt.Errorf("failed to bind flag %s: %w", f.Name, err) 96 | return 97 | } 98 | if strings.HasPrefix(flagName, "tailscale_") { 99 | if err := v.BindPFlag("tailscale."+strings.TrimPrefix(flagName, "tailscale_"), flags.Lookup(f.Name)); err != nil { 100 | bindErr = fmt.Errorf("failed to bind tailscale flag %s: %w", f.Name, err) 101 | return 102 | } 103 | } 104 | }) 105 | if bindErr != nil { 106 | return bindErr 107 | } 108 | 109 | // Useful for debugging viper fully-merged configuration 110 | // spew.Dump(v.AllSettings()) 111 | 112 | if err := v.Unmarshal(c); err != nil { 113 | return fmt.Errorf("failed to unmarshal config: %w", err) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | // bindEnvironmentVariables inspects iface's structure and recursively binds its 120 | // fields to environment variables. This is a workaround to a limitation of 121 | // Viper, found here: 122 | // https://github.com/spf13/viper/issues/188#issuecomment-399884438 123 | func bindEnvironmentVariables(v *viper.Viper, iface interface{}, parts ...string) { 124 | ifv := reflect.ValueOf(iface) 125 | ift := reflect.TypeOf(iface) 126 | for i := range ift.NumField() { 127 | val := ifv.Field(i) 128 | typ := ift.Field(i) 129 | tv, ok := typ.Tag.Lookup("mapstructure") 130 | if !ok { 131 | continue 132 | } 133 | switch val.Kind() { 134 | case reflect.Struct: 135 | bindEnvironmentVariables(v, val.Interface(), append(parts, tv)...) 136 | default: 137 | if err := v.BindEnv(strings.Join(append(parts, tv), ".")); err != nil { 138 | panic(fmt.Sprintf("failed to bind environment variable %s: %v", strings.Join(append(parts, tv), "."), err)) 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tailout/connect.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/netip" 8 | "net/url" 9 | "slices" 10 | 11 | "github.com/cterence/tailout/internal" 12 | "github.com/manifoldco/promptui" 13 | "tailscale.com/client/tailscale" 14 | tsapi "tailscale.com/client/tailscale/v2" 15 | "tailscale.com/ipn" 16 | "tailscale.com/tailcfg" 17 | ) 18 | 19 | func (app *App) Connect(args []string) error { 20 | var nodeConnect string 21 | 22 | nonInteractive := app.Config.NonInteractive 23 | 24 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL) 25 | if err != nil { 26 | return fmt.Errorf("failed to parse base URL: %w", err) 27 | } 28 | 29 | apiClient := &tsapi.Client{ 30 | APIKey: app.Config.Tailscale.APIKey, 31 | Tailnet: app.Config.Tailscale.Tailnet, 32 | BaseURL: baseURL, 33 | } 34 | 35 | var deviceToConnectTo tsapi.Device 36 | 37 | tailoutDevices, err := internal.GetActiveNodes(apiClient) 38 | if err != nil { 39 | return fmt.Errorf("failed to get active nodes: %w", err) 40 | } 41 | 42 | if len(args) != 0 { 43 | nodeConnect = args[0] 44 | i := slices.IndexFunc(tailoutDevices, func(e tsapi.Device) bool { 45 | return e.Hostname == nodeConnect 46 | }) 47 | deviceToConnectTo = tailoutDevices[i] 48 | } else if !nonInteractive { 49 | if len(tailoutDevices) == 0 { 50 | return errors.New("no tailout node found in your tailnet") 51 | } 52 | 53 | // Use promptui to select a node 54 | prompt := promptui.Select{ 55 | Label: "Select a node", 56 | Items: tailoutDevices, 57 | Templates: &promptui.SelectTemplates{ 58 | Label: "{{ . }}", 59 | Active: "{{ .Hostname | cyan }}", 60 | Inactive: "{{ .Hostname }}", 61 | Selected: "{{ .Hostname | yellow }}", 62 | }, 63 | } 64 | 65 | idx, _, err := prompt.Run() 66 | if err != nil { 67 | return fmt.Errorf("failed to select node: %w", err) 68 | } 69 | 70 | deviceToConnectTo = tailoutDevices[idx] 71 | nodeConnect = deviceToConnectTo.ID 72 | } else { 73 | return errors.New("no node name provided") 74 | } 75 | 76 | var localClient tailscale.LocalClient 77 | 78 | prefs := ipn.NewPrefs() 79 | 80 | prefs.ExitNodeID = tailcfg.StableNodeID(nodeConnect) 81 | prefs.ExitNodeIP = netip.MustParseAddr(deviceToConnectTo.Addresses[0]) 82 | 83 | _, err = localClient.EditPrefs(context.TODO(), &ipn.MaskedPrefs{ 84 | Prefs: *prefs, 85 | ExitNodeIDSet: true, 86 | ExitNodeIPSet: true, 87 | }) 88 | if err != nil { 89 | return fmt.Errorf("failed to run tailscale up command: %w", err) 90 | } 91 | 92 | fmt.Println("Connected.") 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /tailout/create.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/url" 10 | "sort" 11 | "strconv" 12 | "sync" 13 | "time" 14 | 15 | "github.com/adhocore/chin" 16 | "github.com/aws/aws-sdk-go-v2/aws" 17 | "github.com/aws/aws-sdk-go-v2/config" 18 | "github.com/aws/aws-sdk-go-v2/service/ec2" 19 | "github.com/aws/aws-sdk-go-v2/service/ec2/types" 20 | "github.com/aws/aws-sdk-go-v2/service/sts" 21 | "github.com/cterence/tailout/internal" 22 | tsapi "tailscale.com/client/tailscale/v2" 23 | ) 24 | 25 | func (app *App) Create() error { 26 | nonInteractive := app.Config.NonInteractive 27 | region := app.Config.Region 28 | dryRun := app.Config.DryRun 29 | connect := app.Config.Create.Connect 30 | shutdown := app.Config.Create.Shutdown 31 | 32 | if app.Config.Tailscale.AuthKey == "" { 33 | return errors.New("no tailscale auth key found") 34 | } 35 | 36 | // TODO: add option for no shutdown 37 | duration, err := time.ParseDuration(shutdown) 38 | if err != nil { 39 | return fmt.Errorf("failed to parse duration: %w", err) 40 | } 41 | 42 | durationMinutes := int(duration.Minutes()) 43 | if durationMinutes < 1 { 44 | return errors.New("duration must be at least 1 minute") 45 | } 46 | 47 | // Create EC2 service client 48 | 49 | if region == "" && !nonInteractive { 50 | region, err = internal.SelectRegion() 51 | if err != nil { 52 | return fmt.Errorf("failed to select region: %w", err) 53 | } 54 | } else if region == "" && nonInteractive { 55 | return errors.New("selected non-interactive mode but no region was explicitly specified") 56 | } 57 | 58 | cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region)) 59 | if err != nil { 60 | log.Fatalf("unable to load SDK config, %v", err) 61 | } 62 | 63 | ec2Svc := ec2.NewFromConfig(cfg) 64 | 65 | // DescribeImages to get the latest Amazon Linux AMI 66 | amazonLinuxImages, err := ec2Svc.DescribeImages(context.TODO(), &ec2.DescribeImagesInput{ 67 | Filters: []types.Filter{ 68 | { 69 | Name: aws.String("name"), 70 | Values: []string{"al2023-ami-*"}, 71 | }, 72 | { 73 | Name: aws.String("state"), 74 | Values: []string{"available"}, 75 | }, 76 | { 77 | Name: aws.String("is-public"), 78 | Values: []string{"true"}, 79 | }, 80 | { 81 | Name: aws.String("architecture"), 82 | Values: []string{"x86_64"}, 83 | }, 84 | }, 85 | Owners: []string{"amazon"}, 86 | }) 87 | if err != nil { 88 | return fmt.Errorf("failed to describe Amazon Linux images: %w", err) 89 | } 90 | 91 | if len(amazonLinuxImages.Images) == 0 { 92 | return errors.New("no Amazon Linux images found") 93 | } 94 | 95 | sort.Slice(amazonLinuxImages.Images, func(i, j int) bool { 96 | return *amazonLinuxImages.Images[i].CreationDate > *amazonLinuxImages.Images[j].CreationDate 97 | }) 98 | 99 | // Get the latest Amazon Linux AMI ID 100 | latestAMI := amazonLinuxImages.Images[0] 101 | imageID := *latestAMI.ImageId 102 | 103 | // Define the instance details 104 | // TODO: add option for instance type 105 | instanceType := "t3a.micro" 106 | userDataScript := `#!/bin/bash 107 | # Allow ip forwarding 108 | echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf 109 | echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf 110 | sudo sysctl -p /etc/sysctl.conf 111 | 112 | TOKEN=$(curl -sSL -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30") 113 | INSTANCE_ID=$(curl -sSL -H "X-aws-ec2-metadata-token: ${TOKEN}" http://169.254.169.254/latest/meta-data/instance-id) 114 | 115 | curl -fsSL https://tailscale.com/install.sh | sh 116 | sudo tailscale up --auth-key=` + app.Config.Tailscale.AuthKey + ` --hostname=tailout-` + region + `-${INSTANCE_ID} --advertise-exit-node --ssh 117 | sudo echo "sudo shutdown" | at now + ` + strconv.Itoa(durationMinutes) + ` minutes` 118 | 119 | // Encode the string in base64 120 | userDataScriptBase64 := base64.StdEncoding.EncodeToString([]byte(userDataScript)) 121 | 122 | // Create instance input parameters 123 | runInput := &ec2.RunInstancesInput{ 124 | ImageId: aws.String(imageID), 125 | InstanceType: types.InstanceTypeT3aMicro, 126 | MinCount: aws.Int32(1), 127 | MaxCount: aws.Int32(1), 128 | UserData: aws.String(userDataScriptBase64), 129 | DryRun: aws.Bool(dryRun), 130 | InstanceMarketOptions: &types.InstanceMarketOptionsRequest{ 131 | MarketType: types.MarketTypeSpot, 132 | SpotOptions: &types.SpotMarketOptions{ 133 | InstanceInterruptionBehavior: types.InstanceInterruptionBehaviorTerminate, 134 | }, 135 | }, 136 | } 137 | 138 | stsSvc := sts.NewFromConfig(cfg) 139 | 140 | identity, err := stsSvc.GetCallerIdentity(context.TODO(), &sts.GetCallerIdentityInput{}) 141 | if err != nil { 142 | return fmt.Errorf("failed to get account ID: %w", err) 143 | } 144 | 145 | fmt.Printf(`Creating tailout node in AWS with the following parameters: 146 | - AWS Account ID: %s 147 | - AMI ID: %s 148 | - Instance Type: %s 149 | - Region: %s 150 | - Auto shutdown after: %s 151 | - Connect after instance up: %v 152 | - Network: default VPC / Subnet / Security group of the region 153 | `, *identity.Account, imageID, instanceType, region, shutdown, connect) 154 | 155 | if !nonInteractive { 156 | result, err := internal.PromptYesNo("Do you want to create this instance?") 157 | if err != nil { 158 | return fmt.Errorf("failed to prompt for confirmation: %w", err) 159 | } 160 | 161 | if !result { 162 | return nil 163 | } 164 | } 165 | 166 | // Run the EC2 instance 167 | runResult, err := ec2Svc.RunInstances(context.TODO(), runInput) 168 | if err != nil { 169 | return fmt.Errorf("failed to create EC2 instance: %w", err) 170 | } 171 | 172 | if len(runResult.Instances) == 0 { 173 | fmt.Println("No instances created.") 174 | return nil 175 | } 176 | 177 | createdInstance := runResult.Instances[0] 178 | fmt.Println("EC2 instance created successfully:", *createdInstance.InstanceId) 179 | nodeName := fmt.Sprintf("tailout-%s-%s", region, *createdInstance.InstanceId) 180 | fmt.Println("Instance will be named", nodeName) 181 | // Create tags for the instance 182 | tags := []types.Tag{ 183 | { 184 | Key: aws.String("App"), 185 | Value: aws.String("tailout"), 186 | }, 187 | { 188 | Key: aws.String("Name"), 189 | Value: aws.String(nodeName), 190 | }, 191 | } 192 | 193 | // Add the tags to the instance 194 | _, err = ec2Svc.CreateTags(context.TODO(), &ec2.CreateTagsInput{ 195 | Resources: []string{*createdInstance.InstanceId}, 196 | Tags: tags, 197 | }) 198 | if err != nil { 199 | return fmt.Errorf("failed to add tags to the instance: %w", err) 200 | } 201 | 202 | // Initialize loading spinner 203 | var wg sync.WaitGroup 204 | var s *chin.Chin 205 | 206 | if !nonInteractive { 207 | s = chin.New().WithWait(&wg) 208 | go s.Start() 209 | } 210 | 211 | fmt.Println("Waiting for instance to be running...") 212 | 213 | // Add a handler for the instance state change event 214 | err = ec2.NewInstanceExistsWaiter(ec2Svc).Wait(context.TODO(), &ec2.DescribeInstancesInput{ 215 | InstanceIds: []string{*createdInstance.InstanceId}, 216 | }, time.Minute*2) 217 | if err != nil { 218 | return fmt.Errorf("failed to wait for instance to be created: %w", err) 219 | } 220 | 221 | fmt.Println("OK.") 222 | fmt.Println("Waiting for instance to join tailnet...") 223 | 224 | // Call internal.GetNodes periodically and search for the instance 225 | // If the instance is found, print the command to use it as an exit node 226 | 227 | timeout := time.Now().Add(3 * time.Minute) 228 | 229 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL) 230 | if err != nil { 231 | return fmt.Errorf("failed to parse base URL: %w", err) 232 | } 233 | 234 | client := &tsapi.Client{ 235 | APIKey: app.Config.Tailscale.APIKey, 236 | Tailnet: app.Config.Tailscale.Tailnet, 237 | BaseURL: baseURL, 238 | } 239 | 240 | for { 241 | nodes, err := client.Devices().List(context.TODO()) 242 | if err != nil { 243 | return fmt.Errorf("failed to get devices: %w", err) 244 | } 245 | 246 | for _, node := range nodes { 247 | if node.Hostname == nodeName { 248 | goto found 249 | } 250 | } 251 | 252 | // Timeouts after 2 minutes 253 | if time.Now().After(timeout) { 254 | return errors.New("timeout waiting for instance to join tailnet") 255 | } 256 | 257 | time.Sleep(2 * time.Second) 258 | } 259 | 260 | found: 261 | // Stop the loading spinner 262 | if !nonInteractive { 263 | s.Stop() 264 | wg.Wait() 265 | } 266 | // Get public IP address of created instance 267 | describeInput := &ec2.DescribeInstancesInput{ 268 | InstanceIds: []string{*createdInstance.InstanceId}, 269 | } 270 | 271 | describeResult, err := ec2Svc.DescribeInstances(context.TODO(), describeInput) 272 | if err != nil { 273 | return fmt.Errorf("failed to describe EC2 instance: %w", err) 274 | } 275 | 276 | if len(describeResult.Reservations) == 0 { 277 | return errors.New("no reservations found") 278 | } 279 | 280 | reservation := describeResult.Reservations[0] 281 | if len(reservation.Instances) == 0 { 282 | return errors.New("no instances found") 283 | } 284 | 285 | instance := reservation.Instances[0] 286 | if instance.PublicIpAddress == nil { 287 | return errors.New("no public IP address found") 288 | } 289 | 290 | fmt.Printf("Node %s joined tailnet.\n", nodeName) 291 | fmt.Println("Public IP address:", *instance.PublicIpAddress) 292 | fmt.Println("Planned termination time:", time.Now().Add(duration).Format(time.RFC3339)) 293 | 294 | if connect { 295 | fmt.Println() 296 | args := []string{nodeName} 297 | err = app.Connect(args) 298 | if err != nil { 299 | return fmt.Errorf("failed to connect to node: %w", err) 300 | } 301 | } 302 | return nil 303 | } 304 | -------------------------------------------------------------------------------- /tailout/disconnect.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/netip" 8 | 9 | "tailscale.com/client/tailscale" 10 | "tailscale.com/ipn" 11 | ) 12 | 13 | func (app *App) Disconnect() error { 14 | var localClient tailscale.LocalClient 15 | prefs, err := localClient.GetPrefs(context.TODO()) 16 | if err != nil { 17 | return fmt.Errorf("failed to get prefs: %w", err) 18 | } 19 | 20 | if prefs.ExitNodeID == "" { 21 | return errors.New("not connected to an exit node") 22 | } 23 | 24 | disconnectPrefs := ipn.NewPrefs() 25 | 26 | disconnectPrefs.ExitNodeID = "" 27 | disconnectPrefs.ExitNodeIP = netip.Addr{} 28 | 29 | _, err = localClient.EditPrefs(context.TODO(), &ipn.MaskedPrefs{ 30 | Prefs: *disconnectPrefs, 31 | ExitNodeIDSet: true, 32 | ExitNodeIPSet: true, 33 | }) 34 | if err != nil { 35 | return fmt.Errorf("failed to run tailscale up command: %w", err) 36 | } 37 | 38 | fmt.Println("Disconnected.") 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /tailout/init.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | 9 | "github.com/cterence/tailout/internal" 10 | tsapi "tailscale.com/client/tailscale/v2" 11 | ) 12 | 13 | func (app *App) Init() error { 14 | dryRun := app.Config.DryRun 15 | nonInteractive := app.Config.NonInteractive 16 | 17 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL) 18 | if err != nil { 19 | return fmt.Errorf("failed to parse base URL: %w", err) 20 | } 21 | 22 | apiClient := &tsapi.Client{ 23 | APIKey: app.Config.Tailscale.APIKey, 24 | Tailnet: app.Config.Tailscale.Tailnet, 25 | BaseURL: baseURL, 26 | } 27 | 28 | // Get the ACL configuration 29 | acl, err := apiClient.PolicyFile().Get(context.TODO()) 30 | if err != nil { 31 | return fmt.Errorf("failed to get acl: %w", err) 32 | } 33 | 34 | allowTailoutSSH := tsapi.ACLSSH{ 35 | Action: "check", 36 | Source: []string{"autogroup:member"}, 37 | Destination: []string{"tag:tailout"}, 38 | Users: []string{"autogroup:nonroot", "root"}, 39 | } 40 | 41 | tailoutSSHConfigExists, tailoutTagExists, tailoutAutoApproversExists := false, false, false 42 | 43 | for _, sshConfig := range acl.SSH { 44 | if sshConfig.Action == "check" && sshConfig.Source[0] == "autogroup:member" && sshConfig.Destination[0] == "tag:tailout" && sshConfig.Users[0] == "autogroup:nonroot" && sshConfig.Users[1] == "root" { 45 | tailoutSSHConfigExists = true 46 | } 47 | } 48 | 49 | if acl.TagOwners["tag:tailout"] != nil { 50 | fmt.Println("Tag 'tag:tailout' already exists.") 51 | tailoutTagExists = true 52 | } else { 53 | acl.TagOwners["tag:tailout"] = []string{} 54 | } 55 | 56 | if acl.AutoApprovers == nil { 57 | fmt.Println("Auto approvers configuration does not exist.") 58 | acl.AutoApprovers = &tsapi.ACLAutoApprovers{} 59 | } 60 | 61 | for _, exitNode := range acl.AutoApprovers.ExitNode { 62 | if exitNode == "tag:tailout" { 63 | fmt.Println("Auto approvers for tag:tailout nodes already exists.") 64 | tailoutAutoApproversExists = true 65 | } 66 | } 67 | 68 | if !tailoutAutoApproversExists { 69 | acl.AutoApprovers.ExitNode = append(acl.AutoApprovers.ExitNode, "tag:tailout") 70 | } 71 | 72 | if tailoutSSHConfigExists { 73 | fmt.Println("SSH configuration for tailout already exists.") 74 | } else { 75 | acl.SSH = append(acl.SSH, allowTailoutSSH) 76 | } 77 | 78 | if tailoutTagExists && tailoutAutoApproversExists && tailoutSSHConfigExists && !dryRun { 79 | fmt.Println("Nothing to do.") 80 | return nil 81 | } 82 | 83 | // Validate the updated acl configuration 84 | err = apiClient.PolicyFile().Validate(context.TODO(), *acl) 85 | if err != nil { 86 | return fmt.Errorf("failed to validate acl: %w", err) 87 | } 88 | 89 | // Update the acl configuration 90 | aclJSON, err := json.MarshalIndent(acl, "", " ") 91 | if err != nil { 92 | return fmt.Errorf("failed to marshal acl: %w", err) 93 | } 94 | 95 | // Make a prompt to show the update that will be done 96 | fmt.Printf(` 97 | The following update to the acl will be done: 98 | - Add tag:tailout to tagOwners 99 | - Update auto approvers to allow exit nodes tagged with tag:tailout 100 | - Add a SSH configuration allowing users to SSH into tagged tailout nodes 101 | 102 | Your new acl document will look like this: 103 | %s 104 | `, aclJSON) 105 | 106 | if !dryRun { 107 | if !nonInteractive { 108 | result, err := internal.PromptYesNo("Do you want to continue?") 109 | if err != nil { 110 | return fmt.Errorf("failed to prompt for confirmation: %w", err) 111 | } 112 | 113 | if !result { 114 | fmt.Println("Aborting...") 115 | return nil 116 | } 117 | } 118 | 119 | err = apiClient.PolicyFile().Set(context.TODO(), *acl, "") 120 | if err != nil { 121 | return fmt.Errorf("failed to update acl: %w", err) 122 | } 123 | 124 | fmt.Println("ACL updated.") 125 | } else { 126 | fmt.Println("Dry run, not updating acl.") 127 | } 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /tailout/status.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/netip" 9 | "net/url" 10 | "slices" 11 | 12 | "github.com/cterence/tailout/internal" 13 | "tailscale.com/client/tailscale" 14 | tsapi "tailscale.com/client/tailscale/v2" 15 | ) 16 | 17 | func (app *App) Status() error { 18 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL) 19 | if err != nil { 20 | return fmt.Errorf("failed to parse base URL: %w", err) 21 | } 22 | 23 | client := &tsapi.Client{ 24 | APIKey: app.Config.Tailscale.APIKey, 25 | Tailnet: app.Config.Tailscale.Tailnet, 26 | BaseURL: baseURL, 27 | } 28 | 29 | nodes, err := internal.GetActiveNodes(client) 30 | if err != nil { 31 | return fmt.Errorf("failed to get active nodes: %w", err) 32 | } 33 | 34 | var localClient tailscale.LocalClient 35 | status, err := localClient.Status(context.TODO()) 36 | if err != nil { 37 | return fmt.Errorf("failed to get tailscale preferences: %w", err) 38 | } 39 | 40 | var currentNode tsapi.Device 41 | 42 | if status.ExitNodeStatus != nil { 43 | i := slices.IndexFunc(nodes, func(e tsapi.Device) bool { 44 | return netip.MustParsePrefix(e.Addresses[0]+"/32") == status.ExitNodeStatus.TailscaleIPs[0] 45 | }) 46 | currentNode = nodes[i] 47 | } 48 | 49 | if len(nodes) == 0 { 50 | fmt.Println("No active node created by tailout found.") 51 | } else { 52 | fmt.Println("Active nodes created by tailout:") 53 | for _, node := range nodes { 54 | if currentNode.Hostname == node.Hostname { 55 | fmt.Println("-", node.Hostname, "[Connected]") 56 | } else { 57 | fmt.Println("-", node.Hostname) 58 | } 59 | } 60 | } 61 | 62 | // Query for the public IP address of this Node 63 | httpClient := &http.Client{} 64 | req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "https://ifconfig.me/ip", nil) 65 | if err != nil { 66 | return fmt.Errorf("failed to create request: %w", err) 67 | } 68 | resp, err := httpClient.Do(req) 69 | if err != nil { 70 | return fmt.Errorf("failed to get public IP: %w", err) 71 | } 72 | defer resp.Body.Close() 73 | 74 | ipAddr, err := io.ReadAll(resp.Body) 75 | if err != nil { 76 | return fmt.Errorf("failed to get public IP: %w", err) 77 | } 78 | 79 | fmt.Println("Public IP: " + string(ipAddr)) 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /tailout/stop.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/url" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/aws/aws-sdk-go-v2/aws" 13 | "github.com/aws/aws-sdk-go-v2/config" 14 | "github.com/aws/aws-sdk-go-v2/service/ec2" 15 | "github.com/cterence/tailout/internal" 16 | "github.com/ktr0731/go-fuzzyfinder" 17 | tsapi "tailscale.com/client/tailscale/v2" 18 | ) 19 | 20 | func (app *App) Stop(args []string) error { 21 | nonInteractive := app.Config.NonInteractive 22 | dryRun := app.Config.DryRun 23 | stopAll := app.Config.Stop.All 24 | 25 | nodesToStop := []tsapi.Device{} 26 | 27 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL) 28 | if err != nil { 29 | return fmt.Errorf("failed to parse base URL: %w", err) 30 | } 31 | 32 | client := &tsapi.Client{ 33 | APIKey: app.Config.Tailscale.APIKey, 34 | Tailnet: app.Config.Tailscale.Tailnet, 35 | BaseURL: baseURL, 36 | } 37 | 38 | tailoutNodes, err := internal.GetActiveNodes(client) 39 | if err != nil { 40 | return fmt.Errorf("failed to get active nodes: %w", err) 41 | } 42 | 43 | if len(tailoutNodes) == 0 { 44 | fmt.Println("No tailout node found in your tailnet") 45 | return nil 46 | } 47 | 48 | if len(args) == 0 && !nonInteractive && !stopAll { 49 | // Create a fuzzy finder selector with the tailout nodes 50 | idx, err := fuzzyfinder.FindMulti(tailoutNodes, func(i int) string { 51 | return tailoutNodes[i].Hostname 52 | }) 53 | if err != nil { 54 | return fmt.Errorf("failed to find node: %w", err) 55 | } 56 | 57 | nodesToStop = []tsapi.Device{} 58 | for _, i := range idx { 59 | nodesToStop = append(nodesToStop, tailoutNodes[i]) 60 | } 61 | } else { 62 | if !stopAll { 63 | for _, node := range tailoutNodes { 64 | for _, arg := range args { 65 | if node.Hostname == arg { 66 | nodesToStop = append(nodesToStop, node) 67 | } 68 | } 69 | } 70 | } else { 71 | nodesToStop = tailoutNodes 72 | } 73 | } 74 | 75 | if !nonInteractive { 76 | fmt.Println("The following nodes will be stopped:") 77 | for _, node := range nodesToStop { 78 | fmt.Println("-", node.Hostname) 79 | } 80 | 81 | result, err := internal.PromptYesNo("Are you sure you want to stop these Nodes?") 82 | if err != nil { 83 | return fmt.Errorf("failed to prompt for confirmation: %w", err) 84 | } 85 | 86 | if !result { 87 | fmt.Println("Aborting...") 88 | return nil 89 | } 90 | } 91 | 92 | // TODO: cleanup tailout instances that were not last seen recently 93 | // TODO: warning when stopping a device to which you are connected, propose to disconnect before 94 | for _, node := range nodesToStop { 95 | fmt.Println("Stopping", node.Hostname) 96 | 97 | regionNames, err := internal.GetRegions() 98 | if err != nil { 99 | return fmt.Errorf("failed to retrieve regions: %w", err) 100 | } 101 | var region string 102 | for _, regionName := range regionNames { 103 | if strings.Contains(node.Hostname, regionName) { 104 | region = regionName 105 | } 106 | } 107 | 108 | if region == "" { 109 | return errors.New("failed to extract region from node name") 110 | } 111 | 112 | // Create a session to share configuration, and load external configuration. 113 | cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region)) 114 | if err != nil { 115 | log.Fatalf("unable to load SDK config, %v", err) 116 | } 117 | 118 | ec2Svc := ec2.NewFromConfig(cfg) 119 | 120 | // Extract the instance ID from the Node name with a regex 121 | 122 | instanceID := regexp.MustCompile(`i\-[a-z0-9]{17}$`).FindString(node.Hostname) 123 | 124 | _, err = ec2Svc.TerminateInstances(context.TODO(), &ec2.TerminateInstancesInput{ 125 | DryRun: aws.Bool(dryRun), 126 | InstanceIds: []string{instanceID}, 127 | }) 128 | if err != nil { 129 | return fmt.Errorf("failed to terminate instance: %w", err) 130 | } 131 | 132 | fmt.Println("Successfully terminated instance", node.Hostname) 133 | 134 | err = client.Devices().Delete(context.TODO(), node.ID) 135 | if err != nil { 136 | return fmt.Errorf("failed to delete node from tailnet: %w", err) 137 | } 138 | 139 | fmt.Println("Successfully deleted node", node.Hostname) 140 | } 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /tailout/ui.go: -------------------------------------------------------------------------------- 1 | package tailout 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/cterence/tailout/internal" 11 | "github.com/cterence/tailout/internal/views" 12 | tsapi "tailscale.com/client/tailscale/v2" 13 | 14 | "github.com/a-h/templ" 15 | ) 16 | 17 | func (app *App) UI(args []string) error { 18 | indexComponent := views.Index() 19 | app.Config.NonInteractive = true 20 | 21 | baseURL, err := url.Parse(app.Config.Tailscale.BaseURL) 22 | if err != nil { 23 | return fmt.Errorf("failed to parse base URL: %w", err) 24 | } 25 | 26 | client := &tsapi.Client{ 27 | APIKey: app.Config.Tailscale.APIKey, 28 | Tailnet: app.Config.Tailscale.Tailnet, 29 | BaseURL: baseURL, 30 | } 31 | 32 | http.Handle("/", templ.Handler(indexComponent)) 33 | 34 | http.HandleFunc("/create", func(w http.ResponseWriter, r *http.Request) { 35 | slog.Info("Creating tailout node") 36 | go func() { 37 | err := app.Create() 38 | if err != nil { 39 | slog.Error("failed to create node", "error", err) 40 | } 41 | }() 42 | w.WriteHeader(http.StatusCreated) 43 | }) 44 | 45 | http.HandleFunc("/stop", func(w http.ResponseWriter, r *http.Request) { 46 | slog.Info("Stopping tailout nodes") 47 | app.Config.Stop.All = true 48 | go func() { 49 | err := app.Stop(nil) 50 | if err != nil { 51 | slog.Error("failed to stop nodes", "error", err) 52 | } 53 | }() 54 | w.WriteHeader(http.StatusNoContent) 55 | }) 56 | 57 | http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { 58 | nodes, err := internal.GetActiveNodes(client) 59 | if err != nil { 60 | slog.Error("failed to get active nodes", "error", err) 61 | w.WriteHeader(http.StatusInternalServerError) 62 | return 63 | } 64 | table := "" 65 | for _, node := range nodes { 66 | table += fmt.Sprintf("%s%s%s", node.Hostname, node.Addresses[0], node.LastSeen) 67 | } 68 | if _, err := w.Write([]byte(table)); err != nil { 69 | slog.Error("failed to write response", "error", err) 70 | } 71 | }) 72 | 73 | http.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | if _, err := w.Write([]byte(`{"status": {"server": "OK"}}`)); err != nil { 75 | slog.Error("failed to write health check response", "error", err) 76 | } 77 | })) 78 | 79 | // Serve assets files 80 | http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("internal/assets")))) 81 | 82 | slog.Info("Server starting", "address", app.Config.UI.Address, "port", app.Config.UI.Port) 83 | srv := &http.Server{ 84 | Addr: app.Config.UI.Address + ":" + app.Config.UI.Port, 85 | ReadTimeout: 5 * time.Second, 86 | WriteTimeout: 10 * time.Second, 87 | IdleTimeout: 120 * time.Second, 88 | } 89 | 90 | if err := srv.ListenAndServe(); err != nil { 91 | slog.Error("Failed to start server", "error", err) 92 | panic(err) 93 | } 94 | return nil 95 | } 96 | --------------------------------------------------------------------------------