initial commit

Signed-off-by: Lee Briggs <lee@leebriggs.co.uk>
Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
Lee Briggs 2025-07-14 12:22:07 -04:00 committed by Percy Wegmann
parent 540ac650e6
commit ece0596be3
14 changed files with 110220 additions and 0 deletions

56
.github/workflows/smoke-test.yml vendored Normal file
View 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
View 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
View file

@ -0,0 +1 @@
node_modules

21
LICENSE Normal file
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load diff

31
package.json Normal file
View 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
View 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
View 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
View 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"]
}