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