Delete Inactive Tailnet Nodes #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Delete Inactive Tailnet Nodes | |
| permissions: | |
| id-token: write # Required for OIDC token | |
| contents: read # Required for checkout | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| tags: | |
| description: "Comma-separated list of tags to filter devices (e.g., 'tag:k8s,tag:ottawa')" | |
| required: true | |
| default: "tag:k8s,tag:ottawa" | |
| inactive_days: | |
| description: "Number of days of inactivity before deletion" | |
| required: true | |
| default: "30" | |
| dry_run: | |
| description: "Dry run mode (only list devices, don't delete)" | |
| type: boolean | |
| default: true | |
| audience: | |
| description: "Audience for the OIDC token" | |
| required: true | |
| default: "api.tailscale.com/kxF3d4zoso11CNTRL" | |
| client_id: | |
| description: "Client ID for the Tailscale OIDC JWT exchange" | |
| required: true | |
| default: "TbqNGJkY5611CNTRL/kxF3d4zoso11CNTRL" | |
| jobs: | |
| delete-inactive-nodes: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v5 | |
| - name: Install dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y jq curl | |
| - name: Get OIDC token from GitHub Actions | |
| id: get_oidc_token | |
| run: | | |
| JWT=$(curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${{ inputs.audience }}" | jq -r '.value') | |
| echo "::add-mask::$JWT" | |
| echo "jwt=$JWT" >> $GITHUB_OUTPUT | |
| - name: Exchange OIDC token for access token | |
| id: get_access_token | |
| run: | | |
| echo "Exchanging OIDC token with Tailscale..." | |
| ACCESS_TOKEN=$(./tailscale/scripts/exchange-oidc-token.sh "${{ inputs.client_id }}" "${{ steps.get_oidc_token.outputs.jwt }}") | |
| echo "::add-mask::$ACCESS_TOKEN" | |
| echo "access_token=$ACCESS_TOKEN" >> $GITHUB_OUTPUT | |
| echo "Successfully obtained access token" | |
| - name: Get all devices in tailnet | |
| id: get_devices | |
| run: | | |
| # Extract tailnet from the client_id (format: clientid/tailnet) | |
| TAILNET=$(echo "${{ inputs.client_id }}" | cut -d'/' -f2) | |
| echo "Fetching devices from tailnet: $TAILNET" | |
| echo "tailnet=$TAILNET" >> $GITHUB_OUTPUT | |
| # Make API call with proper error handling | |
| RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ | |
| https://api.tailscale.com/api/v2/tailnet/"$TAILNET"/devices \ | |
| --header "Authorization: Bearer ${{ steps.get_access_token.outputs.access_token }}") | |
| HTTP_STATUS=$(echo "$RESPONSE" | tail -n 1 | cut -d: -f2) | |
| DEVICES=$(echo "$RESPONSE" | sed '$d') | |
| echo "API Response HTTP Status: $HTTP_STATUS" | |
| if [ "$HTTP_STATUS" != "200" ]; then | |
| echo "Error: Failed to fetch devices with status $HTTP_STATUS" >&2 | |
| echo "Response body: $DEVICES" >&2 | |
| exit 1 | |
| fi | |
| # Check if response is valid JSON | |
| if ! echo "$DEVICES" | jq empty 2>/dev/null; then | |
| echo "Error: Invalid JSON response from API" >&2 | |
| echo "Response: $DEVICES" >&2 | |
| exit 1 | |
| fi | |
| echo "$DEVICES" > /tmp/all_devices.json | |
| # Debug: show first device if any | |
| DEVICE_COUNT=$(echo "$DEVICES" | jq '.devices | length') | |
| echo "Total devices found: $DEVICE_COUNT" | |
| if [ "$DEVICE_COUNT" -gt 0 ]; then | |
| echo "Sample device:" | |
| echo "$DEVICES" | jq '.devices[0] | {name, nodeId, tags, lastSeen}' | |
| fi | |
| - name: Filter devices by tags and inactivity | |
| id: filter_devices | |
| run: | | |
| echo "Filtering devices by tags: ${{ inputs.tags }}" | |
| echo "Checking for devices inactive for more than ${{ inputs.inactive_days }} days" | |
| # Check if we have devices to process | |
| if [ ! -f /tmp/all_devices.json ]; then | |
| echo "Error: Device list file not found" >&2 | |
| exit 1 | |
| fi | |
| DEVICE_COUNT=$(jq '.devices | length' /tmp/all_devices.json) | |
| if [ "$DEVICE_COUNT" -eq 0 ]; then | |
| echo "No devices found in tailnet to process" | |
| echo "[]" > /tmp/devices_to_delete.json | |
| echo "devices_count=0" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| # Convert tags input to array | |
| IFS=',' read -ra TAGS_ARRAY <<< "${{ inputs.tags }}" | |
| echo "Looking for devices with tags: ${TAGS_ARRAY[@]}" | |
| # Get current date in seconds | |
| CURRENT_DATE=$(date +%s) | |
| INACTIVE_THRESHOLD=$(( ${{ inputs.inactive_days }} * 86400 )) | |
| # Initialize empty array for devices to delete | |
| echo "[]" > /tmp/devices_to_delete.json | |
| # Process each device | |
| while IFS= read -r device; do | |
| DEVICE_NAME=$(echo "$device" | jq -r '.name') | |
| DEVICE_ID=$(echo "$device" | jq -r '.nodeId') | |
| DEVICE_TAGS=$(echo "$device" | jq -r '.tags[]?' 2>/dev/null) | |
| LAST_SEEN=$(echo "$device" | jq -r '.lastSeen') | |
| # Debug: show device info | |
| echo "Checking device: $DEVICE_NAME (ID: $DEVICE_ID)" | |
| echo " Tags: $(echo "$DEVICE_TAGS" | tr '\n' ' ')" | |
| # Check if device has any of the specified tags | |
| HAS_TAG=false | |
| for TAG in "${TAGS_ARRAY[@]}"; do | |
| TAG=$(echo "$TAG" | xargs) # Trim whitespace | |
| if echo "$DEVICE_TAGS" | grep -q "^$TAG$"; then | |
| echo " Matched tag: $TAG" | |
| HAS_TAG=true | |
| break | |
| fi | |
| done | |
| if [ "$HAS_TAG" = true ]; then | |
| # Check if device is inactive | |
| if [ "$LAST_SEEN" != "null" ] && [ -n "$LAST_SEEN" ]; then | |
| LAST_SEEN_SECONDS=$(date -d "$LAST_SEEN" +%s 2>/dev/null || echo 0) | |
| if [ "$LAST_SEEN_SECONDS" -gt 0 ]; then | |
| INACTIVE_TIME=$(( CURRENT_DATE - LAST_SEEN_SECONDS )) | |
| if [ "$INACTIVE_TIME" -gt "$INACTIVE_THRESHOLD" ]; then | |
| INACTIVE_DAYS=$(( INACTIVE_TIME / 86400 )) | |
| echo "Device $DEVICE_NAME (ID: $DEVICE_ID) has been inactive for $INACTIVE_DAYS days" | |
| # Add to deletion list | |
| jq --arg name "$DEVICE_NAME" --arg id "$DEVICE_ID" --arg days "$INACTIVE_DAYS" --arg last "$LAST_SEEN" \ | |
| '. += [{"name": $name, "id": $id, "inactive_days": $days, "last_seen": $last}]' \ | |
| /tmp/devices_to_delete.json > /tmp/devices_to_delete_tmp.json | |
| mv /tmp/devices_to_delete_tmp.json /tmp/devices_to_delete.json | |
| fi | |
| fi | |
| else | |
| echo "Warning: Device $DEVICE_NAME has no lastSeen timestamp" | |
| fi | |
| fi | |
| done < <(jq -c '.devices[]' /tmp/all_devices.json) | |
| DELETION_COUNT=$(jq '. | length' /tmp/devices_to_delete.json) | |
| echo "Found $DELETION_COUNT devices to delete" | |
| echo "devices_count=$DELETION_COUNT" >> $GITHUB_OUTPUT | |
| - name: Display devices to delete | |
| run: | | |
| echo "=== Devices to Delete ===" | |
| if [ $(jq '. | length' /tmp/devices_to_delete.json) -eq 0 ]; then | |
| echo "No devices found matching the criteria" | |
| else | |
| jq -r '.[] | "Device: \(.name) | ID: \(.id) | Inactive Days: \(.inactive_days) | Last Seen: \(.last_seen)"' /tmp/devices_to_delete.json | |
| fi | |
| echo "=========================" | |
| - name: Delete inactive devices | |
| if: inputs.dry_run == false && steps.filter_devices.outputs.devices_count != '0' | |
| run: | | |
| echo "Starting device deletion (NOT a dry run)..." | |
| SUCCESS_COUNT=0 | |
| FAILED_COUNT=0 | |
| while IFS= read -r device; do | |
| DEVICE_NAME=$(echo "$device" | jq -r '.name') | |
| DEVICE_ID=$(echo "$device" | jq -r '.id') | |
| echo "Deleting device: $DEVICE_NAME (ID: $DEVICE_ID)" | |
| RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X DELETE https://api.tailscale.com/api/v2/device/"$DEVICE_ID" \ | |
| --header "Authorization: Bearer ${{ steps.get_access_token.outputs.access_token }}") | |
| HTTP_STATUS=$(echo "$RESPONSE" | tail -n 1 | cut -d: -f2) | |
| if [ "$HTTP_STATUS" = "200" ]; then | |
| echo "Successfully deleted device: $DEVICE_NAME" | |
| SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) | |
| else | |
| echo "Failed to delete device: $DEVICE_NAME (HTTP Status: $HTTP_STATUS)" | |
| RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d') | |
| echo "Error response: $RESPONSE_BODY" | |
| FAILED_COUNT=$((FAILED_COUNT + 1)) | |
| fi | |
| # Add small delay between deletions to avoid rate limiting | |
| sleep 1 | |
| done < <(jq -c '.[]' /tmp/devices_to_delete.json) | |
| echo "=== Deletion Summary ===" | |
| echo "Successfully deleted: $SUCCESS_COUNT devices" | |
| echo "Failed to delete: $FAILED_COUNT devices" | |
| echo "========================" | |
| if [ "$FAILED_COUNT" -gt 0 ]; then | |
| exit 1 | |
| fi | |
| - name: Dry run summary | |
| if: inputs.dry_run == true | |
| run: | | |
| echo "=== DRY RUN MODE ===" | |
| echo "No devices were actually deleted." | |
| echo "To perform actual deletion, run the workflow with 'dry_run' set to false." | |
| echo "====================" | |
| - name: Final device list | |
| if: inputs.dry_run == false && steps.filter_devices.outputs.devices_count != '0' | |
| run: | | |
| echo "Fetching updated device list..." | |
| TAILNET=$(echo "${{ inputs.client_id }}" | cut -d'/' -f2) | |
| ./tailscale/scripts/list-devices.sh "${{ steps.get_access_token.outputs.access_token }}" "$TAILNET" table || true |