mirror of
https://github.com/tailscale/github-action.git
synced 2026-08-20 02:39:22 +00:00
initial commit
Signed-off-by: Lee Briggs <lee@leebriggs.co.uk> Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
parent
540ac650e6
commit
ece0596be3
14 changed files with 110220 additions and 0 deletions
56
.github/workflows/smoke-test.yml
vendored
Normal file
56
.github/workflows/smoke-test.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: "Smoke Tests"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
# Basic smoke test that doesn't require secrets
|
||||
smoke-test:
|
||||
name: Smoke Test (${{ matrix.os }})
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Action
|
||||
run: npm run build
|
||||
|
||||
- name: Test Action Loading (No Auth)
|
||||
uses: ./
|
||||
with:
|
||||
version: "1.82.0"
|
||||
continue-on-error: true
|
||||
id: smoke-test
|
||||
|
||||
# The action should fail gracefully with a proper error message
|
||||
- name: Verify Expected Failure
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ steps.smoke-test.outcome }}" == "success" ]; then
|
||||
echo "❌ Expected action to fail without authentication, but it succeeded"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Action correctly failed without authentication as expected"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
238
.github/workflows/test.yml
vendored
Normal file
238
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
name: "Integration Tests"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# Test building the action
|
||||
build:
|
||||
name: Build Action
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Action
|
||||
run: npm run build
|
||||
|
||||
|
||||
# Matrix test for all supported platforms and architectures
|
||||
|
||||
integration-tests:
|
||||
name: Test ${{ matrix.os }} (${{ matrix.arch }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Linux tests (AMD64)
|
||||
- os: ubuntu-latest
|
||||
runner-os: Linux
|
||||
arch: amd64
|
||||
|
||||
# Linux tests (ARM64)
|
||||
- os: ubuntu-24.04-arm
|
||||
runner-os: Linux
|
||||
arch: arm64
|
||||
|
||||
# Windows tests (AMD64)
|
||||
- os: windows-latest
|
||||
runner-os: Windows
|
||||
arch: amd64
|
||||
|
||||
# Windows tests (ARM64)
|
||||
- os: windows-11-arm
|
||||
runner-os: Windows
|
||||
arch: arm64
|
||||
|
||||
# macOS intel
|
||||
- os: macos-13
|
||||
runner-os: macOS
|
||||
arch: amd64
|
||||
|
||||
# macOS ARM
|
||||
- os: macos-14
|
||||
runner-os: macOS
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Test with OAuth authentication
|
||||
- name: Test Tailscale Setup (OAuth)
|
||||
id: tailscale-oauth
|
||||
uses: ./
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-client-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: "tag:ci"
|
||||
version: "1.82.0"
|
||||
use-cache: true
|
||||
timeout: "3m"
|
||||
retry: 3
|
||||
|
||||
# Test Tailscale status command
|
||||
- name: Check Tailscale Status
|
||||
if: steps.tailscale-oauth.outcome == 'success'
|
||||
run: |
|
||||
echo "Testing Tailscale status command..."
|
||||
if [ "${{ matrix.runner-os }}" == "Windows" ]; then
|
||||
# Windows uses system-installed binary without sudo
|
||||
tailscale status
|
||||
tailscale version
|
||||
else
|
||||
# Linux and macOS use system-installed binary with sudo
|
||||
sudo -E tailscale status
|
||||
tailscale version
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
# Speed comparison between our action and the official Tailscale action
|
||||
speed-comparison:
|
||||
name: Speed Comparison (${{ matrix.os }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Test on a subset of platforms for speed comparison
|
||||
- os: ubuntu-latest
|
||||
runner-os: Linux
|
||||
- os: windows-latest
|
||||
runner-os: Windows
|
||||
- os: macos-14
|
||||
runner-os: macOS
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Test our action with timing
|
||||
- name: Test Our Action (jaxxstorm/action-setup-tailscale)
|
||||
id: our-action
|
||||
run: |
|
||||
echo "::group::Our Action Performance Test"
|
||||
start_time=$(date +%s%N)
|
||||
echo "START_TIME=$start_time" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Run Our Action
|
||||
uses: ./
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-client-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: "tag:ci"
|
||||
version: "1.82.0"
|
||||
use-cache: true
|
||||
timeout: "3m"
|
||||
retry: 3
|
||||
|
||||
- name: Calculate Our Action Time
|
||||
run: |
|
||||
end_time=$(date +%s%N)
|
||||
duration=$(( (end_time - START_TIME) / 1000000 ))
|
||||
echo "OUR_ACTION_TIME=${duration}ms" >> $GITHUB_ENV
|
||||
echo "::notice::Our action completed in ${duration}ms"
|
||||
echo "::endgroup::"
|
||||
shell: bash
|
||||
|
||||
# Clean up for next test
|
||||
- name: Cleanup Between Tests
|
||||
run: |
|
||||
echo "::group::Cleanup Between Tests"
|
||||
if [ "${{ matrix.runner-os }}" == "Windows" ]; then
|
||||
# Windows cleanup
|
||||
tailscale logout || true
|
||||
# Stop and remove Tailscale service if needed
|
||||
sc stop Tailscale || true
|
||||
# Uninstall via registry or control panel if needed
|
||||
else
|
||||
# Linux/macOS cleanup
|
||||
sudo -E tailscale logout || true
|
||||
if [ "${{ matrix.runner-os }}" == "macOS" ]; then
|
||||
sudo launchctl stop com.tailscale.tailscaled || true
|
||||
sudo launchctl unload /Library/LaunchDaemons/com.tailscale.tailscaled.plist || true
|
||||
brew uninstall tailscale || true
|
||||
else
|
||||
sudo pkill tailscaled || true
|
||||
sudo apt-get remove -y tailscale || true
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
|
||||
# Test official Tailscale action with timing
|
||||
- name: Test Official Action (tailscale/github-action)
|
||||
run: |
|
||||
echo "::group::Official Action Performance Test"
|
||||
start_time=$(date +%s%N)
|
||||
echo "OFFICIAL_START_TIME=$start_time" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Run Official Action
|
||||
uses: tailscale/github-action@v2
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: "tag:ci"
|
||||
version: "1.82.0"
|
||||
|
||||
- name: Calculate Official Action Time & Compare
|
||||
run: |
|
||||
end_time=$(date +%s%N)
|
||||
official_duration=$(( (end_time - OFFICIAL_START_TIME) / 1000000 ))
|
||||
echo "OFFICIAL_ACTION_TIME=${official_duration}ms" >> $GITHUB_ENV
|
||||
echo "::endgroup::"
|
||||
|
||||
# Performance comparison
|
||||
echo "::group::Performance Comparison Results"
|
||||
echo "📊 **Performance Comparison on ${{ matrix.os }}:**"
|
||||
echo "🚀 Our Action: ${OUR_ACTION_TIME}"
|
||||
echo "🏢 Official Action: ${official_duration}ms"
|
||||
|
||||
if [ "${OUR_ACTION_TIME%ms}" -lt "${official_duration}" ]; then
|
||||
improvement=$(( official_duration - ${OUR_ACTION_TIME%ms} ))
|
||||
percentage=$(( improvement * 100 / official_duration ))
|
||||
echo "✅ Our action is ${improvement}ms (${percentage}%) faster!"
|
||||
echo "::notice::Performance Win: Our action is ${improvement}ms (${percentage}%) faster than official action on ${{ matrix.os }}"
|
||||
elif [ "${OUR_ACTION_TIME%ms}" -gt "${official_duration}" ]; then
|
||||
regression=$(( ${OUR_ACTION_TIME%ms} - official_duration ))
|
||||
percentage=$(( regression * 100 / ${OUR_ACTION_TIME%ms} ))
|
||||
echo "⚠️ Our action is ${regression}ms (${percentage}%) slower"
|
||||
echo "::warning::Performance Regression: Our action is ${regression}ms (${percentage}%) slower than official action on ${{ matrix.os }}"
|
||||
else
|
||||
echo "🤝 Both actions have similar performance"
|
||||
echo "::notice::Performance Tie: Both actions have similar performance on ${{ matrix.os }}"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
shell: bash
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Lee Briggs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
42
Makefile
Normal file
42
Makefile
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Makefile for building a TypeScript GitHub Action
|
||||
|
||||
# Variables
|
||||
SHELL := /bin/bash
|
||||
SRC_DIR := src
|
||||
BUILD_DIR := dist
|
||||
ENTRY_POINT := $(SRC_DIR)/index.ts
|
||||
|
||||
# Binaries
|
||||
TS_NODE := ./node_modules/.bin/ts-node
|
||||
TS_C := ./node_modules/.bin/tsc
|
||||
ESLINT := ./node_modules/.bin/eslint
|
||||
PRETTIER := ./node_modules/.bin/prettier
|
||||
|
||||
# Targets
|
||||
.PHONY: all clean install build format
|
||||
|
||||
all: clean install build
|
||||
|
||||
# Clean up the lib directory
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
# Install npm dependencies
|
||||
install:
|
||||
npm install
|
||||
|
||||
# Build the TypeScript code
|
||||
build: clean
|
||||
npm run build
|
||||
|
||||
# Lint the TypeScript code
|
||||
lint:
|
||||
$(ESLINT) $(SRC_DIR)
|
||||
|
||||
# Format the TypeScript code
|
||||
format:
|
||||
$(PRETTIER) --write "$(SRC_DIR)/**/*.ts"
|
||||
|
||||
# Run the action locally (for testing purposes)
|
||||
run: build
|
||||
|
||||
209
README.md
Normal file
209
README.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Connect Tailscale GitHub Action
|
||||
|
||||
A fast, reliable GitHub Action cross platform GitHub action to connect your GitHub runners to Tailscale.
|
||||
|
||||
## Why Use This Action?
|
||||
|
||||
While the [official Tailscale action](https://github.com/tailscale/github-action) is great, it is a [composite action](https://docs.github.com/en/actions/tutorials/creating-a-composite-action) which means some useful things aren't available to it.
|
||||
|
||||
This action is written in Typescript using official GitHub SDKs. It provides some improvements to the official action that might be interesting to you, such as:
|
||||
|
||||
### 🧹 **Automatic Cleanup**
|
||||
- **Post-job logout**: Automatically runs `tailscale logout` when the job completes, ensuring clean disconnection
|
||||
|
||||
### ⚡ **Performance Optimizations**
|
||||
- **Native TypeScript implementation**: Compiled to single JavaScript files for faster startup
|
||||
- **Smart status checking**: Calls the localAPI to determine when the connection is ready, reducing the need for sleeps within the action
|
||||
- **Modified Defaults**: The usage of more reliable status checking means the backoffs and retries can be tuned
|
||||
|
||||
### 🔧 **Enhanced Cross-Platform Support**
|
||||
- **Native Support for All GitHub supported OSS**: Supports Linux, Windows, and macOS runners and all architectures
|
||||
- **Native crypto verification**: Uses Node.js crypto module instead of external tools for SHA256 verification
|
||||
- **Improved Windows handling**: Better MSI installation and authentication timing
|
||||
- **macOS via Homebrew**: Simple and reliable installation using `brew install tailscale`
|
||||
- **Consistent caching**: Caching built using the TypeScript SDKs meaning more flexibility.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```yaml
|
||||
- name: Connect to Tailscale
|
||||
uses: jaxxstorm/action-setup-tailscale@v1
|
||||
with:
|
||||
authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
|
||||
version: latest
|
||||
```
|
||||
|
||||
### OAuth Authentication (Recommended)
|
||||
|
||||
```yaml
|
||||
- name: Connect to Tailscale
|
||||
uses: jaxxstorm/action-setup-tailscale@v1
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-client-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: "ci,github-actions"
|
||||
version: latest
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```yaml
|
||||
- name: Connect to Tailscale
|
||||
uses: jaxxstorm/action-setup-tailscale@v1
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-client-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: "ci,github-actions,deploy"
|
||||
version: "1.82.0"
|
||||
hostname: "ci-${{ github.run_id }}"
|
||||
timeout: "30s"
|
||||
retry: 3
|
||||
use-cache: true
|
||||
args: "--ssh"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
|-------|-------------|----------|---------|
|
||||
| `authkey` | Tailscale authentication key | false | |
|
||||
| `oauth-client-id` | OAuth Client ID | false | |
|
||||
| `oauth-client-secret` | OAuth Client Secret | false | |
|
||||
| `tags` | Comma-separated list of tags | false | |
|
||||
| `version` | Tailscale version to install | true | `1.82.0` |
|
||||
| `hostname` | Custom hostname | false | `github-<runner-name>` |
|
||||
| `timeout` | Connection timeout | false | `60s` |
|
||||
| `retry` | Number of retry attempts | false | `5` |
|
||||
| `use-cache` | Enable binary caching | false | `false` |
|
||||
| `args` | Additional `tailscale up` arguments | false | |
|
||||
| `tailscaled-args` | Additional `tailscaled` arguments | false | |
|
||||
| `statedir` | State directory (if empty, uses memory) | false | |
|
||||
| `sha256sum` | Expected SHA256 checksum | false | |
|
||||
|
||||
## Authentication
|
||||
|
||||
### OAuth (Recommended)
|
||||
|
||||
OAuth provides better security and is the recommended approach:
|
||||
|
||||
1. Create an OAuth client in the [Tailscale admin panel](https://tailscale.com/s/oauth-clients)
|
||||
2. Grant necessary permissions (typically "Write" for devices)
|
||||
3. Add the client ID and secret to your GitHub repository secrets
|
||||
4. Specify appropriate tags that the OAuth client can manage
|
||||
|
||||
### Auth Key (Legacy)
|
||||
|
||||
While still supported, auth keys are less secure for CI/CD:
|
||||
|
||||
1. Generate an auth key in the Tailscale admin panel
|
||||
2. Add it to your GitHub repository secrets
|
||||
3. Use the `authkey` input
|
||||
|
||||
## Platform Support
|
||||
|
||||
- ✅ **Linux** (Ubuntu, Amazon Linux, etc.)
|
||||
- ✅ **Windows** (Windows Server 2019, 2022)
|
||||
- ✅ **macOS** (macOS 11, 12, 13+)
|
||||
|
||||
## Caching
|
||||
|
||||
Enable caching to speed up subsequent workflow runs:
|
||||
|
||||
```yaml
|
||||
- uses: jaxxstorm/action-setup-tailscale@v1
|
||||
with:
|
||||
use-cache: true
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Linux/macOS**: Caches extracted binaries
|
||||
- **Windows**: Caches MSI installer
|
||||
- **All platforms**: Includes SHA256 verification for integrity
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Feature | This Action | Official Action |
|
||||
|---------|-------------|-----------------|
|
||||
| Default timeout | 60s | 2m |
|
||||
| Retry interval | 2s incremental | 5s fixed |
|
||||
| Windows status check | Native command | HTTP/Named pipes |
|
||||
| Crypto verification | Native Node.js | External tools |
|
||||
| Post-job cleanup | ✅ Automatic | ❌ Manual |
|
||||
| MSI caching | ✅ Supported | ❌ Not available |
|
||||
|
||||
## Examples
|
||||
|
||||
### Deploy to Private Server
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Connect to Tailscale
|
||||
uses: jaxxstorm/action-setup-tailscale@v1
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-client-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: "ci,deploy"
|
||||
use-cache: true
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
ssh deploy@private-server "deploy.sh"
|
||||
```
|
||||
|
||||
### Multi-Platform Testing
|
||||
|
||||
```yaml
|
||||
name: Test
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Connect to Tailscale
|
||||
uses: jaxxstorm/action-setup-tailscale@v1
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-client-secret: ${{ secrets.TAILSCALE_OAUTH_CLIENT_SECRET }}
|
||||
tags: "ci,test"
|
||||
hostname: "test-${{ matrix.os }}-${{ github.run_id }}"
|
||||
use-cache: true
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
# Your tests that require Tailscale connectivity
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run `npm run build` to compile TypeScript
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Security
|
||||
|
||||
This action automatically logs out of Tailscale when the job completes, ensuring no persistent connections remain. For OAuth authentication, connections are ephemeral by default.
|
||||
|
||||
For security issues, please see our [security policy](SECURITY.md).
|
||||
61
action.yml
Normal file
61
action.yml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
name: 'Connect Tailscale'
|
||||
description: 'Connect your GitHub Action workflow to Tailscale'
|
||||
branding:
|
||||
icon: 'arrow-right-circle'
|
||||
color: 'gray-dark'
|
||||
|
||||
inputs:
|
||||
authkey:
|
||||
description: 'Your Tailscale authentication key, from the admin panel.'
|
||||
required: false
|
||||
deprecationMessage: 'An OAuth API client https://tailscale.com/s/oauth-clients is recommended instead of an authkey'
|
||||
oauth-client-id:
|
||||
description: 'Your Tailscale OAuth Client ID.'
|
||||
required: false
|
||||
oauth-client-secret:
|
||||
description: 'Your Tailscale OAuth Client Secret.'
|
||||
required: false
|
||||
tags:
|
||||
description: 'Comma separated list of tags to be applied to nodes (OAuth client must have permission to apply these tags).'
|
||||
required: false
|
||||
version:
|
||||
description: 'Tailscale version to use. Specify `latest` for the latest stable version.'
|
||||
required: true
|
||||
args:
|
||||
description: 'Optional additional arguments to `tailscale up`.'
|
||||
required: false
|
||||
default: ''
|
||||
tailscaled-args:
|
||||
description: 'Optional additional arguments to `tailscaled`.'
|
||||
required: false
|
||||
default: ''
|
||||
hostname:
|
||||
description: 'Fixed hostname to use.'
|
||||
required: false
|
||||
default: ''
|
||||
timeout:
|
||||
description: 'Timeout for `tailscale up`.'
|
||||
required: false
|
||||
default: '2m'
|
||||
retry:
|
||||
description: 'Number of retry attempts for Tailscale connection.'
|
||||
required: false
|
||||
default: '5'
|
||||
use-cache:
|
||||
description: 'Enable caching of Tailscale binaries to speed up subsequent runs.'
|
||||
required: false
|
||||
default: 'true'
|
||||
statedir:
|
||||
description: 'Directory to store Tailscale state. If empty, uses in-memory storage.'
|
||||
required: false
|
||||
default: ''
|
||||
sha256sum:
|
||||
description: 'Expected SHA256 checksum of the Tailscale package. If not provided, it will be fetched automatically.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'dist/index.js'
|
||||
post: 'dist/logout/index.js'
|
||||
|
||||
80007
dist/index.js
vendored
Normal file
80007
dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
27652
dist/logout/index.js
vendored
Normal file
27652
dist/logout/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1142
package-lock.json
generated
Normal file
1142
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "action-tailscale",
|
||||
"version": "0.0.1",
|
||||
"description": "Install Tailscale and connect to a tailnet",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "ncc build src/main.ts -o dist && ncc build src/logout/logout.ts -o dist/logout"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jaxxstorm/action-setup-tailscale"
|
||||
},
|
||||
"author": "Lee Briggs",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@actions/cache": "^4.0.0",
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@actions/tool-cache": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.6",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"glob": "^10.3.10",
|
||||
"inflight": "npm:@isaacs/inflight@^1.0.6"
|
||||
}
|
||||
}
|
||||
48
src/logout/logout.ts
Normal file
48
src/logout/logout.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import * as core from '@actions/core';
|
||||
import * as exec from '@actions/exec';
|
||||
import * as os from 'os';
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
const runnerOS = process.env.RUNNER_OS || '';
|
||||
|
||||
core.info('🔄 Logging out of Tailscale...');
|
||||
|
||||
// Check if tailscale is available first
|
||||
try {
|
||||
await exec.exec('tailscale', ['--version'], { silent: true });
|
||||
} catch (error) {
|
||||
core.info('Tailscale not found or not accessible, skipping logout');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the correct command based on OS
|
||||
let execArgs: string[];
|
||||
if (runnerOS === 'Windows') {
|
||||
execArgs = ['tailscale', 'logout'];
|
||||
} else {
|
||||
// Linux and macOS - use system-installed binary with sudo
|
||||
execArgs = ['sudo', '-E', 'tailscale', 'logout'];
|
||||
}
|
||||
|
||||
core.info(`Running: ${execArgs.join(' ')}`);
|
||||
|
||||
try {
|
||||
await exec.exec(execArgs[0], execArgs.slice(1));
|
||||
core.info('✅ Successfully logged out of Tailscale');
|
||||
} catch (error) {
|
||||
// Don't fail the action if logout fails - it's just cleanup
|
||||
core.warning(`Failed to logout from Tailscale: ${error}`);
|
||||
core.info('Your ephemeral node will eventually be cleaned up by Tailscale');
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the action for post-cleanup issues
|
||||
core.warning(`Post-action cleanup error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the logout function
|
||||
logout().catch(error => {
|
||||
// Even if logout fails, don't fail the action
|
||||
core.warning(`Logout process failed: ${error}`);
|
||||
});
|
||||
649
src/main.ts
Normal file
649
src/main.ts
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
import * as core from '@actions/core';
|
||||
import * as exec from '@actions/exec';
|
||||
import * as tc from '@actions/tool-cache';
|
||||
import * as cache from '@actions/cache';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import * as crypto from 'crypto';
|
||||
import * as http from 'http';
|
||||
|
||||
interface TailscaleConfig {
|
||||
version: string;
|
||||
resolvedVersion: string;
|
||||
arch: string;
|
||||
authKey: string;
|
||||
oauthClientId: string;
|
||||
oauthSecret: string;
|
||||
tags: string;
|
||||
hostname: string;
|
||||
args: string;
|
||||
tailscaledArgs: string;
|
||||
stateDir: string;
|
||||
timeout: string;
|
||||
retry: number;
|
||||
useCache: boolean;
|
||||
sha256Sum: string;
|
||||
}
|
||||
|
||||
// Cross-platform Tailscale local API status check
|
||||
async function getTailscaleStatus(): Promise<any> {
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Windows: use tailscale status command
|
||||
const { stdout } = await exec.getExecOutput('tailscale', ['status', '--json']);
|
||||
return JSON.parse(stdout);
|
||||
} else if (platform === 'darwin') {
|
||||
// macOS with Homebrew: use /var/run/tailscaled.socket
|
||||
return new Promise((resolve, reject) => {
|
||||
const options: http.RequestOptions = {
|
||||
socketPath: '/var/run/tailscaled.socket',
|
||||
path: '/localapi/v0/status',
|
||||
method: 'GET',
|
||||
headers: { Host: 'local-tailscaled.sock' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set timeout to prevent hanging
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
} else {
|
||||
// Linux: use Unix socket
|
||||
return new Promise((resolve, reject) => {
|
||||
const options: http.RequestOptions = {
|
||||
socketPath: '/run/tailscale/tailscaled.sock',
|
||||
path: '/localapi/v0/status',
|
||||
method: 'GET',
|
||||
headers: { Host: 'local-tailscaled.sock' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set timeout to prevent hanging
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
// Validate runner OS
|
||||
const runnerOS = process.env.RUNNER_OS || '';
|
||||
if (!['Linux', 'Windows', 'macOS'].includes(runnerOS)) {
|
||||
throw new Error('Support Linux, Windows, and macOS Only');
|
||||
}
|
||||
|
||||
// Get and validate inputs
|
||||
const config = await getInputs();
|
||||
|
||||
// Validate authentication
|
||||
validateAuth(config);
|
||||
|
||||
// Resolve version
|
||||
config.resolvedVersion = await resolveVersion(config.version);
|
||||
core.info(`Resolved Tailscale version: ${config.resolvedVersion}`);
|
||||
|
||||
// Set architecture
|
||||
config.arch = getTailscaleArch(runnerOS);
|
||||
|
||||
// Install Tailscale
|
||||
await installTailscale(config, runnerOS);
|
||||
|
||||
// Start daemon (non-Windows only)
|
||||
if (runnerOS !== 'Windows') {
|
||||
await startTailscaleDaemon(config);
|
||||
}
|
||||
|
||||
// Connect to Tailscale
|
||||
await connectToTailscale(config, runnerOS);
|
||||
|
||||
// Check Tailscale status (cross-platform)
|
||||
try {
|
||||
const status = await getTailscaleStatus();
|
||||
core.debug(`Tailscale status: ${JSON.stringify(status)}`);
|
||||
if (status.BackendState === 'Running') {
|
||||
core.info('✅ Tailscale is running and connected!');
|
||||
// Explicitly exit to prevent hanging
|
||||
process.exit(0);
|
||||
} else {
|
||||
core.setFailed(`❌ Tailscale backend state: ${status.BackendState}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
core.warning(`Failed to get Tailscale status: ${err}`);
|
||||
// Still exit successfully since the main connection worked
|
||||
core.info('✅ Tailscale connection completed successfully!');
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function getInputs(): Promise<TailscaleConfig> {
|
||||
return {
|
||||
version: core.getInput('version') || '1.82.0',
|
||||
resolvedVersion: '',
|
||||
arch: '',
|
||||
authKey: core.getInput('authkey') || '',
|
||||
oauthClientId: core.getInput('oauth-client-id') || '',
|
||||
oauthSecret: core.getInput('oauth-client-secret') || '',
|
||||
tags: core.getInput('tags') || '',
|
||||
hostname: core.getInput('hostname') || '',
|
||||
args: core.getInput('args') || '',
|
||||
tailscaledArgs: core.getInput('tailscaled-args') || '',
|
||||
stateDir: core.getInput('statedir') || '',
|
||||
timeout: core.getInput('timeout') || '60s', // Reduced from 2m to 60s
|
||||
retry: parseInt(core.getInput('retry') || '5'),
|
||||
useCache: core.getBooleanInput('use-cache'),
|
||||
sha256Sum: core.getInput('sha256sum') || ''
|
||||
};
|
||||
}
|
||||
|
||||
function validateAuth(config: TailscaleConfig): void {
|
||||
if (!config.authKey && (!config.oauthSecret || !config.tags)) {
|
||||
throw new Error('OAuth identity empty, please provide either an auth key or OAuth secret and tags.');
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveVersion(version: string): Promise<string> {
|
||||
if (version === 'latest') {
|
||||
const { stdout } = await exec.getExecOutput('curl', [
|
||||
'-H', 'user-agent:action-setup-tailscale',
|
||||
'-s',
|
||||
'https://pkgs.tailscale.com/stable/?mode=json'
|
||||
]);
|
||||
const response = JSON.parse(stdout);
|
||||
return response.Version;
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function getTailscaleArch(runnerOS: string): string {
|
||||
const runnerArch = process.env.RUNNER_ARCH || '';
|
||||
|
||||
if (runnerOS === 'Linux') {
|
||||
switch (runnerArch) {
|
||||
case 'ARM64': return 'arm64';
|
||||
case 'ARM': return 'arm';
|
||||
case 'X86': return '386';
|
||||
default: return 'amd64';
|
||||
}
|
||||
} else if (runnerOS === 'Windows') {
|
||||
switch (runnerArch) {
|
||||
case 'ARM64': return 'arm64';
|
||||
case 'X86': return 'x86';
|
||||
default: return 'amd64';
|
||||
}
|
||||
}
|
||||
return 'amd64';
|
||||
}
|
||||
|
||||
async function installTailscale(config: TailscaleConfig, runnerOS: string): Promise<void> {
|
||||
const cacheKey = generateCacheKey(config, runnerOS);
|
||||
const toolPath = getToolPath(config, runnerOS);
|
||||
|
||||
// Try to restore from cache first
|
||||
if (config.useCache && cacheKey) {
|
||||
const cacheHit = await cache.restoreCache([toolPath], cacheKey);
|
||||
if (cacheHit) {
|
||||
core.info(`Found Tailscale ${config.resolvedVersion} in cache: ${toolPath}`);
|
||||
|
||||
// For Windows, install the cached MSI
|
||||
if (runnerOS === 'Windows') {
|
||||
await installTailscaleWindows(config, toolPath, true);
|
||||
} else {
|
||||
// For Linux/macOS, copy binaries to /usr/bin
|
||||
await installCachedBinaries(toolPath, runnerOS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Install fresh if not cached
|
||||
if (runnerOS === 'Linux') {
|
||||
await installTailscaleLinux(config, toolPath);
|
||||
} else if (runnerOS === 'Windows') {
|
||||
await installTailscaleWindows(config, toolPath);
|
||||
} else if (runnerOS === 'macOS') {
|
||||
await installTailscaleMacOS(config, toolPath);
|
||||
}
|
||||
|
||||
// Save to cache after installation
|
||||
if (config.useCache && cacheKey) {
|
||||
try {
|
||||
await cache.saveCache([toolPath], cacheKey);
|
||||
core.info(`Cached Tailscale ${config.resolvedVersion} at: ${toolPath}`);
|
||||
} catch (error) {
|
||||
const typedError = error as Error;
|
||||
if (typedError.name === cache.ValidationError.name) {
|
||||
throw error;
|
||||
} else if (typedError.name === cache.ReserveCacheError.name) {
|
||||
core.info(typedError.message);
|
||||
} else {
|
||||
core.warning(`Cache save failed: ${typedError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function calculateFileSha256(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('error', err => reject(err));
|
||||
stream.on('data', chunk => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex').toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
async function installTailscaleLinux(config: TailscaleConfig, toolPath: string): Promise<void> {
|
||||
// Determine if stable or unstable
|
||||
const minor = parseInt(config.resolvedVersion.split('.')[1]);
|
||||
const isStable = minor % 2 === 0;
|
||||
const baseUrl = isStable ? 'https://pkgs.tailscale.com/stable' : 'https://pkgs.tailscale.com/unstable';
|
||||
|
||||
// Get SHA256 if not provided
|
||||
if (!config.sha256Sum) {
|
||||
const shaUrl = `${baseUrl}/tailscale_${config.resolvedVersion}_${config.arch}.tgz.sha256`;
|
||||
const { stdout } = await exec.getExecOutput('curl', [
|
||||
'-H', 'user-agent:action-setup-tailscale',
|
||||
'-L', shaUrl, '--fail'
|
||||
]);
|
||||
config.sha256Sum = stdout.trim();
|
||||
}
|
||||
|
||||
// Download and extract
|
||||
const downloadUrl = `${baseUrl}/tailscale_${config.resolvedVersion}_${config.arch}.tgz`;
|
||||
core.info(`Downloading ${downloadUrl}`);
|
||||
|
||||
const tarPath = await tc.downloadTool(downloadUrl, 'tailscale.tgz');
|
||||
|
||||
// Verify checksum
|
||||
const actualSha = await calculateFileSha256(tarPath);
|
||||
const expectedSha = config.sha256Sum.trim().toLowerCase();
|
||||
core.info(`Expected sha256: ${expectedSha}`);
|
||||
core.info(`Actual sha256: ${actualSha}`);
|
||||
if (actualSha !== expectedSha) {
|
||||
throw new Error('SHA256 checksum mismatch');
|
||||
}
|
||||
|
||||
// Extract to tool path
|
||||
const extractedPath = await tc.extractTar(tarPath, undefined, 'xz');
|
||||
const extractedDir = path.join(extractedPath, `tailscale_${config.resolvedVersion}_${config.arch}`);
|
||||
|
||||
// Create tool directory and copy binaries there for caching
|
||||
fs.mkdirSync(toolPath, { recursive: true });
|
||||
fs.copyFileSync(path.join(extractedDir, 'tailscale'), path.join(toolPath, 'tailscale'));
|
||||
fs.copyFileSync(path.join(extractedDir, 'tailscaled'), path.join(toolPath, 'tailscaled'));
|
||||
|
||||
// Install binaries to /usr/bin
|
||||
await exec.exec('sudo', ['cp',
|
||||
path.join(toolPath, 'tailscale'),
|
||||
path.join(toolPath, 'tailscaled'),
|
||||
'/usr/bin'
|
||||
]);
|
||||
|
||||
// Make sure they're executable
|
||||
await exec.exec('sudo', ['chmod', '+x', '/usr/bin/tailscale']);
|
||||
await exec.exec('sudo', ['chmod', '+x', '/usr/bin/tailscaled']);
|
||||
}
|
||||
|
||||
async function installTailscaleWindows(config: TailscaleConfig, toolPath: string, fromCache: boolean = false): Promise<void> {
|
||||
// Create tool directory
|
||||
fs.mkdirSync(toolPath, { recursive: true });
|
||||
const msiPath = path.join(toolPath, 'tailscale.msi');
|
||||
|
||||
if (fromCache) {
|
||||
// Installing from cached MSI
|
||||
if (!fs.existsSync(msiPath)) {
|
||||
throw new Error(`Cached MSI not found at ${msiPath}`);
|
||||
}
|
||||
core.info(`Installing cached MSI from ${msiPath}`);
|
||||
} else {
|
||||
// Fresh download
|
||||
// Determine if stable or unstable
|
||||
const minor = parseInt(config.resolvedVersion.split('.')[1]);
|
||||
const isStable = minor % 2 === 0;
|
||||
const baseUrl = isStable ? 'https://pkgs.tailscale.com/stable' : 'https://pkgs.tailscale.com/unstable';
|
||||
|
||||
// Get SHA256 if not provided
|
||||
if (!config.sha256Sum) {
|
||||
const shaUrl = `${baseUrl}/tailscale-setup-${config.resolvedVersion}-${config.arch}.msi.sha256`;
|
||||
const { stdout } = await exec.getExecOutput('curl', [
|
||||
'-H', 'user-agent:action-setup-tailscale',
|
||||
'-L', shaUrl, '--fail'
|
||||
]);
|
||||
config.sha256Sum = stdout.trim();
|
||||
}
|
||||
|
||||
// Download MSI
|
||||
const downloadUrl = `${baseUrl}/tailscale-setup-${config.resolvedVersion}-${config.arch}.msi`;
|
||||
core.info(`Downloading ${downloadUrl}`);
|
||||
|
||||
const downloadedMsiPath = await tc.downloadTool(downloadUrl, msiPath);
|
||||
|
||||
// Verify checksum
|
||||
const actualSha = await calculateFileSha256(downloadedMsiPath);
|
||||
const expectedSha = config.sha256Sum.trim().toLowerCase();
|
||||
core.info(`Expected sha256: ${expectedSha}`);
|
||||
core.info(`Actual sha256: ${actualSha}`);
|
||||
if (actualSha !== expectedSha) {
|
||||
throw new Error('SHA256 checksum mismatch');
|
||||
}
|
||||
|
||||
// Keep the MSI file in toolPath for caching (don't delete it)
|
||||
// The downloadedMsiPath is in temp, but we want to keep it in toolPath
|
||||
if (downloadedMsiPath !== msiPath) {
|
||||
fs.copyFileSync(downloadedMsiPath, msiPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Install MSI (same for both fresh and cached)
|
||||
await exec.exec('msiexec.exe', [
|
||||
'/quiet',
|
||||
`/l*v`, path.join(process.env.RUNNER_TEMP || '', 'tailscale.log'),
|
||||
'/i', msiPath
|
||||
]);
|
||||
|
||||
// Add to PATH
|
||||
core.addPath('C:\\Program Files\\Tailscale\\');
|
||||
}
|
||||
|
||||
async function installTailscaleMacOS(config: TailscaleConfig, toolPath: string): Promise<void> {
|
||||
// macOS: Install via Homebrew
|
||||
core.info('Installing Tailscale via Homebrew on macOS...');
|
||||
|
||||
try {
|
||||
// Check if Homebrew is installed
|
||||
await exec.exec('brew', ['--version'], { silent: true });
|
||||
} catch (error) {
|
||||
throw new Error('Homebrew is required to install Tailscale on macOS. Please ensure Homebrew is installed.');
|
||||
}
|
||||
|
||||
// Install Tailscale via Homebrew
|
||||
core.info('Installing Tailscale from Homebrew...');
|
||||
await exec.exec('brew', ['install', 'tailscale']);
|
||||
|
||||
core.info('✅ Tailscale installed successfully on macOS via Homebrew');
|
||||
}
|
||||
|
||||
async function startTailscaleDaemon(config: TailscaleConfig): Promise<void> {
|
||||
const runnerOS = process.env.RUNNER_OS || '';
|
||||
|
||||
// macOS with Homebrew installation
|
||||
if (runnerOS === 'macOS') {
|
||||
core.info('Starting Tailscale daemon on macOS...');
|
||||
try {
|
||||
// Start tailscaled using sudo (Homebrew installs to /usr/local/bin or /opt/homebrew/bin)
|
||||
await exec.exec('sudo', ['tailscaled', 'install-system-daemon']);
|
||||
core.info('✅ Tailscale system daemon installed');
|
||||
|
||||
// Start the system daemon
|
||||
await exec.exec('sudo', ['launchctl', 'start', 'com.tailscale.tailscaled']);
|
||||
core.info('✅ Tailscale system daemon started');
|
||||
} catch (error) {
|
||||
core.warning(`Failed to install system daemon: ${error}`);
|
||||
core.info('Trying manual daemon start...');
|
||||
|
||||
// Fall back to manual start
|
||||
const stateArgs = config.stateDir ?
|
||||
[`--statedir=${config.stateDir}`] :
|
||||
['--state=mem:'];
|
||||
|
||||
if (config.stateDir) {
|
||||
fs.mkdirSync(config.stateDir, { recursive: true });
|
||||
}
|
||||
|
||||
const args = [
|
||||
...stateArgs,
|
||||
...config.tailscaledArgs.split(' ').filter(Boolean)
|
||||
];
|
||||
|
||||
const daemon = spawn('sudo', ['-E', 'tailscaled', ...args], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'ignore', fs.openSync(path.join(os.homedir(), 'tailscaled.log'), 'w')]
|
||||
});
|
||||
|
||||
daemon.unref();
|
||||
if (daemon.stdin) daemon.stdin.end();
|
||||
if (daemon.stdout) daemon.stdout.destroy();
|
||||
if (daemon.stderr) daemon.stderr.destroy();
|
||||
}
|
||||
} else {
|
||||
// Linux - manual daemon start
|
||||
const stateArgs = config.stateDir ?
|
||||
[`--statedir=${config.stateDir}`] :
|
||||
['--state=mem:'];
|
||||
|
||||
if (config.stateDir) {
|
||||
fs.mkdirSync(config.stateDir, { recursive: true });
|
||||
}
|
||||
|
||||
const args = [
|
||||
...stateArgs,
|
||||
...config.tailscaledArgs.split(' ').filter(Boolean)
|
||||
];
|
||||
|
||||
core.info('Starting tailscaled daemon...');
|
||||
|
||||
// Start daemon in background
|
||||
const daemon = spawn('sudo', ['-E', 'tailscaled', ...args], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'ignore', fs.openSync(path.join(os.homedir(), 'tailscaled.log'), 'w')]
|
||||
});
|
||||
|
||||
daemon.unref(); // Ensure daemon doesn't keep Node.js process alive
|
||||
|
||||
// Close stdin/stdout/stderr to fully detach
|
||||
if (daemon.stdin) daemon.stdin.end();
|
||||
if (daemon.stdout) daemon.stdout.destroy();
|
||||
if (daemon.stderr) daemon.stderr.destroy();
|
||||
}
|
||||
|
||||
// Poll the local API until daemon is responsive
|
||||
await waitForDaemonReady();
|
||||
|
||||
core.info('✅ tailscaled daemon is up and running!');
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(): Promise<void> {
|
||||
const maxWaitMs = 15000; // 15 seconds
|
||||
const pollIntervalMs = 500;
|
||||
let waited = 0;
|
||||
|
||||
core.info('Waiting for tailscaled daemon to become ready...');
|
||||
|
||||
while (waited < maxWaitMs) {
|
||||
try {
|
||||
const status = await getTailscaleStatus();
|
||||
// If we get any valid response from the API, the daemon is ready
|
||||
if (status) {
|
||||
core.info(`Daemon ready! Initial state: ${status.BackendState || 'Unknown'}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Daemon not ready yet, keep polling
|
||||
core.debug(`Waiting for daemon... (${waited}ms elapsed)`);
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
waited += pollIntervalMs;
|
||||
}
|
||||
|
||||
throw new Error('tailscaled daemon did not become ready within timeout');
|
||||
}
|
||||
|
||||
async function connectToTailscale(config: TailscaleConfig, runnerOS: string): Promise<void> {
|
||||
// Determine hostname
|
||||
let hostname = config.hostname;
|
||||
if (!hostname) {
|
||||
if (runnerOS === 'Windows') {
|
||||
hostname = `github-${process.env.COMPUTERNAME}`;
|
||||
} else {
|
||||
const { stdout } = await exec.getExecOutput('hostname');
|
||||
hostname = `github-${stdout.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare auth and tags
|
||||
let finalAuthKey = config.authKey;
|
||||
const tagsArg: string[] = [];
|
||||
|
||||
if (config.oauthSecret) {
|
||||
finalAuthKey = `${config.oauthSecret}?preauthorized=true&ephemeral=true`;
|
||||
if (config.tags) {
|
||||
tagsArg.push(`--advertise-tags=${config.tags}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific args
|
||||
const platformArgs: string[] = [];
|
||||
if (runnerOS === 'Windows') {
|
||||
platformArgs.push('--unattended');
|
||||
}
|
||||
|
||||
// Build command
|
||||
const upArgs = [
|
||||
'up',
|
||||
...tagsArg,
|
||||
`--authkey=${finalAuthKey}`,
|
||||
`--hostname=${hostname}`,
|
||||
'--accept-routes',
|
||||
...platformArgs,
|
||||
...config.args.split(' ').filter(Boolean)
|
||||
];
|
||||
|
||||
// Retry logic
|
||||
for (let attempt = 1; attempt <= config.retry; attempt++) {
|
||||
try {
|
||||
core.info(`Attempt ${attempt} to bring up Tailscale...`);
|
||||
|
||||
let execArgs: string[];
|
||||
if (runnerOS === 'Windows') {
|
||||
execArgs = ['tailscale', ...upArgs];
|
||||
} else {
|
||||
// Linux and macOS - use system-installed binary with sudo
|
||||
execArgs = ['sudo', '-E', 'tailscale', ...upArgs];
|
||||
}
|
||||
|
||||
const timeoutMs = parseTimeout(config.timeout);
|
||||
core.info(`Running: ${execArgs.join(' ')} (timeout: ${timeoutMs}ms)`);
|
||||
|
||||
await Promise.race([
|
||||
exec.exec(execArgs[0], execArgs.slice(1)),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
|
||||
)
|
||||
]);
|
||||
|
||||
// Success
|
||||
core.info(`✅ Tailscale up command completed successfully on attempt ${attempt}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.warning(`Tailscale up attempt ${attempt} failed: ${error}`);
|
||||
if (attempt === config.retry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sleepTime = attempt * 2; // Reduced from 5 to 2 seconds
|
||||
core.info(`Retrying in ${sleepTime} seconds...`);
|
||||
await sleep(sleepTime * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimeout(timeout: string): number {
|
||||
const match = timeout.match(/^(\d+)([smh]?)$/);
|
||||
if (!match) return 120000; // default 2 minutes
|
||||
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2] || 's';
|
||||
|
||||
switch (unit) {
|
||||
case 's': return value * 1000;
|
||||
case 'm': return value * 60 * 1000;
|
||||
case 'h': return value * 60 * 60 * 1000;
|
||||
default: return value * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function generateCacheKey(config: TailscaleConfig, runnerOS: string): string | undefined {
|
||||
// Don't cache if version is latest or if caching is disabled
|
||||
if (config.resolvedVersion === 'latest' || !config.useCache) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `action-setup-tailscale/${config.resolvedVersion}/${runnerOS}-${config.arch}`;
|
||||
}
|
||||
|
||||
function getToolPath(config: TailscaleConfig, runnerOS: string): string {
|
||||
const cacheDirectory = process.env.RUNNER_TOOL_CACHE || '';
|
||||
if (cacheDirectory === '') {
|
||||
core.warning('Expected RUNNER_TOOL_CACHE to be defined');
|
||||
}
|
||||
|
||||
return path.join(
|
||||
cacheDirectory,
|
||||
'tailscale',
|
||||
config.resolvedVersion,
|
||||
`${runnerOS}-${config.arch}`
|
||||
);
|
||||
}
|
||||
|
||||
async function installCachedBinaries(toolPath: string, runnerOS: string): Promise<void> {
|
||||
if (runnerOS === 'Linux' || runnerOS === 'macOS') {
|
||||
// Copy cached binaries to /usr/bin
|
||||
const tailscaleBin = path.join(toolPath, 'tailscale');
|
||||
const tailscaledBin = path.join(toolPath, 'tailscaled');
|
||||
|
||||
if (fs.existsSync(tailscaleBin) && fs.existsSync(tailscaledBin)) {
|
||||
await exec.exec('sudo', ['cp', tailscaleBin, '/usr/bin/tailscale']);
|
||||
await exec.exec('sudo', ['cp', tailscaledBin, '/usr/bin/tailscaled']);
|
||||
await exec.exec('sudo', ['chmod', '+x', '/usr/bin/tailscale']);
|
||||
await exec.exec('sudo', ['chmod', '+x', '/usr/bin/tailscaled']);
|
||||
} else {
|
||||
throw new Error(`Cached binaries not found in ${toolPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
63
tsconfig.json
Normal file
63
tsconfig.json
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "NodeNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./lib", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "NodeNext", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
},
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue