Skip to content

Commit ef822c6

Browse files
committed
Add GitHub Action workflow to delete inactive Tailnet nodes
This workflow enables automated cleanup of inactive devices in the Tailnet based on specified tags and inactivity period. Features include: - Filter devices by multiple tags (e.g., tag:k8s, tag:ottawa) - Configurable inactivity threshold (default: 30 days) - Dry run mode for safety (enabled by default) - OIDC authentication with Tailscale API - Detailed logging of devices to be deleted - Summary report of deletion operations Usage: Run workflow manually with desired parameters to clean up stale nodes.
1 parent 1698d84 commit ef822c6

File tree

1 file changed

+203
-0
lines changed

1 file changed

+203
-0
lines changed
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
name: Delete Inactive Tailnet Nodes
2+
3+
permissions:
4+
id-token: write # Required for OIDC token
5+
contents: read # Required for checkout
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
tags:
11+
description: "Comma-separated list of tags to filter devices (e.g., 'tag:k8s,tag:ottawa')"
12+
required: true
13+
default: "tag:k8s,tag:ottawa"
14+
inactive_days:
15+
description: "Number of days of inactivity before deletion"
16+
required: true
17+
default: "30"
18+
dry_run:
19+
description: "Dry run mode (only list devices, don't delete)"
20+
type: boolean
21+
default: true
22+
audience:
23+
description: "Audience for the OIDC token"
24+
required: true
25+
default: "api.tailscale.com/kxF3d4zoso11CNTRL"
26+
client_id:
27+
description: "Client ID for the Tailscale OIDC JWT exchange"
28+
required: true
29+
default: "TbqNGJkY5611CNTRL/kxF3d4zoso11CNTRL"
30+
tailnet_name:
31+
description: "Tailnet name to operate on"
32+
required: true
33+
default: "rajsinghtech.github"
34+
35+
jobs:
36+
delete-inactive-nodes:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- name: Checkout repository
40+
uses: actions/checkout@v5
41+
42+
- name: Install dependencies
43+
run: |
44+
sudo apt-get update
45+
sudo apt-get install -y jq curl
46+
47+
- name: Get OIDC token from GitHub Actions
48+
id: get_oidc_token
49+
run: |
50+
JWT=$(curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${{ inputs.audience }}" | jq -r '.value')
51+
echo "::add-mask::$JWT"
52+
echo "jwt=$JWT" >> $GITHUB_OUTPUT
53+
54+
- name: Exchange OIDC token for access token
55+
id: get_access_token
56+
run: |
57+
echo "Exchanging OIDC token with Tailscale..."
58+
ACCESS_TOKEN=$(./tailscale/scripts/exchange-oidc-token.sh "${{ inputs.client_id }}" "${{ steps.get_oidc_token.outputs.jwt }}")
59+
echo "::add-mask::$ACCESS_TOKEN"
60+
echo "access_token=$ACCESS_TOKEN" >> $GITHUB_OUTPUT
61+
echo "Successfully obtained access token"
62+
63+
- name: Get all devices in tailnet
64+
id: get_devices
65+
run: |
66+
echo "Fetching devices from tailnet: ${{ inputs.tailnet_name }}"
67+
DEVICES=$(curl -s https://api.tailscale.com/api/v2/tailnet/"${{ inputs.tailnet_name }}"/devices?fields=all \
68+
--header "Authorization: Bearer ${{ steps.get_access_token.outputs.access_token }}")
69+
70+
if [ $? -ne 0 ]; then
71+
echo "Error: Failed to fetch devices" >&2
72+
exit 1
73+
fi
74+
75+
echo "$DEVICES" > /tmp/all_devices.json
76+
echo "Total devices found: $(echo "$DEVICES" | jq '.devices | length')"
77+
78+
- name: Filter devices by tags and inactivity
79+
id: filter_devices
80+
run: |
81+
echo "Filtering devices by tags: ${{ inputs.tags }}"
82+
echo "Checking for devices inactive for more than ${{ inputs.inactive_days }} days"
83+
84+
# Convert tags input to array
85+
IFS=',' read -ra TAGS_ARRAY <<< "${{ inputs.tags }}"
86+
87+
# Get current date in seconds
88+
CURRENT_DATE=$(date +%s)
89+
INACTIVE_THRESHOLD=$(( ${{ inputs.inactive_days }} * 86400 ))
90+
91+
# Initialize empty array for devices to delete
92+
echo "[]" > /tmp/devices_to_delete.json
93+
94+
# Process each device
95+
while IFS= read -r device; do
96+
DEVICE_NAME=$(echo "$device" | jq -r '.name')
97+
DEVICE_ID=$(echo "$device" | jq -r '.nodeId')
98+
DEVICE_TAGS=$(echo "$device" | jq -r '.tags[]?' 2>/dev/null)
99+
LAST_SEEN=$(echo "$device" | jq -r '.lastSeen')
100+
101+
# Check if device has any of the specified tags
102+
HAS_TAG=false
103+
for TAG in "${TAGS_ARRAY[@]}"; do
104+
TAG=$(echo "$TAG" | xargs) # Trim whitespace
105+
if echo "$DEVICE_TAGS" | grep -q "^$TAG$"; then
106+
HAS_TAG=true
107+
break
108+
fi
109+
done
110+
111+
if [ "$HAS_TAG" = true ]; then
112+
# Check if device is inactive
113+
if [ "$LAST_SEEN" != "null" ] && [ -n "$LAST_SEEN" ]; then
114+
LAST_SEEN_SECONDS=$(date -d "$LAST_SEEN" +%s 2>/dev/null || echo 0)
115+
if [ "$LAST_SEEN_SECONDS" -gt 0 ]; then
116+
INACTIVE_TIME=$(( CURRENT_DATE - LAST_SEEN_SECONDS ))
117+
118+
if [ "$INACTIVE_TIME" -gt "$INACTIVE_THRESHOLD" ]; then
119+
INACTIVE_DAYS=$(( INACTIVE_TIME / 86400 ))
120+
echo "Device $DEVICE_NAME (ID: $DEVICE_ID) has been inactive for $INACTIVE_DAYS days"
121+
122+
# Add to deletion list
123+
jq --arg name "$DEVICE_NAME" --arg id "$DEVICE_ID" --arg days "$INACTIVE_DAYS" --arg last "$LAST_SEEN" \
124+
'. += [{"name": $name, "id": $id, "inactive_days": $days, "last_seen": $last}]' \
125+
/tmp/devices_to_delete.json > /tmp/devices_to_delete_tmp.json
126+
mv /tmp/devices_to_delete_tmp.json /tmp/devices_to_delete.json
127+
fi
128+
fi
129+
else
130+
echo "Warning: Device $DEVICE_NAME has no lastSeen timestamp"
131+
fi
132+
fi
133+
done < <(jq -c '.devices[]' /tmp/all_devices.json)
134+
135+
DELETION_COUNT=$(jq '. | length' /tmp/devices_to_delete.json)
136+
echo "Found $DELETION_COUNT devices to delete"
137+
echo "devices_count=$DELETION_COUNT" >> $GITHUB_OUTPUT
138+
139+
- name: Display devices to delete
140+
run: |
141+
echo "=== Devices to Delete ==="
142+
if [ $(jq '. | length' /tmp/devices_to_delete.json) -eq 0 ]; then
143+
echo "No devices found matching the criteria"
144+
else
145+
jq -r '.[] | "Device: \(.name) | ID: \(.id) | Inactive Days: \(.inactive_days) | Last Seen: \(.last_seen)"' /tmp/devices_to_delete.json
146+
fi
147+
echo "========================="
148+
149+
- name: Delete inactive devices
150+
if: inputs.dry_run == false && steps.filter_devices.outputs.devices_count != '0'
151+
run: |
152+
echo "Starting device deletion (NOT a dry run)..."
153+
154+
SUCCESS_COUNT=0
155+
FAILED_COUNT=0
156+
157+
while IFS= read -r device; do
158+
DEVICE_NAME=$(echo "$device" | jq -r '.name')
159+
DEVICE_ID=$(echo "$device" | jq -r '.id')
160+
161+
echo "Deleting device: $DEVICE_NAME (ID: $DEVICE_ID)"
162+
163+
RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X DELETE https://api.tailscale.com/api/v2/device/"$DEVICE_ID" \
164+
--header "Authorization: Bearer ${{ steps.get_access_token.outputs.access_token }}")
165+
166+
HTTP_STATUS=$(echo "$RESPONSE" | tail -n 1 | cut -d: -f2)
167+
168+
if [ "$HTTP_STATUS" = "200" ]; then
169+
echo " Successfully deleted device: $DEVICE_NAME"
170+
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
171+
else
172+
echo "L Failed to delete device: $DEVICE_NAME (HTTP Status: $HTTP_STATUS)"
173+
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
174+
echo "Error response: $RESPONSE_BODY"
175+
FAILED_COUNT=$((FAILED_COUNT + 1))
176+
fi
177+
178+
# Add small delay between deletions to avoid rate limiting
179+
sleep 1
180+
done < <(jq -c '.[]' /tmp/devices_to_delete.json)
181+
182+
echo "=== Deletion Summary ==="
183+
echo "Successfully deleted: $SUCCESS_COUNT devices"
184+
echo "Failed to delete: $FAILED_COUNT devices"
185+
echo "========================"
186+
187+
if [ "$FAILED_COUNT" -gt 0 ]; then
188+
exit 1
189+
fi
190+
191+
- name: Dry run summary
192+
if: inputs.dry_run == true
193+
run: |
194+
echo "=== DRY RUN MODE ==="
195+
echo "No devices were actually deleted."
196+
echo "To perform actual deletion, run the workflow with 'dry_run' set to false."
197+
echo "===================="
198+
199+
- name: Final device list
200+
if: inputs.dry_run == false && steps.filter_devices.outputs.devices_count != '0'
201+
run: |
202+
echo "Fetching updated device list..."
203+
./tailscale/scripts/list-devices.sh "${{ steps.get_access_token.outputs.access_token }}" "${{ inputs.tailnet_name }}" table || true

0 commit comments

Comments
 (0)