From de09ea0456db9650c5646fba61e8ed321dfd6168 Mon Sep 17 00:00:00 2001 From: drifty Date: Mon, 6 Oct 2025 19:24:16 +0530 Subject: [PATCH] Add files via upload --- Dockerfile | 13 ++ compose.yml | 17 +++ main.py | 304 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 4 files changed, 337 insertions(+) create mode 100644 Dockerfile create mode 100644 compose.yml create mode 100644 main.py create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..602fcba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +# Create data directory for state persistence +RUN mkdir -p /data + +CMD ["python3", "-u", "main.py"] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..07d16ed --- /dev/null +++ b/compose.yml @@ -0,0 +1,17 @@ + version: '3.8' + + services: + tailscale-monitor: +# build: . + image: ghcr.io/driftywinds/tailmon + container_name: tailscale-monitor + restart: unless-stopped + env_file: + - .env + volumes: + - ./data:/data + environment: + - TZ=UTC + dns: + - 8.8.8.8 + - 8.8.4.4 diff --git a/main.py b/main.py new file mode 100644 index 0000000..a59cff7 --- /dev/null +++ b/main.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +Tailscale Device Status Monitor with Apprise Notifications +""" + +import os +import time +import json +import requests +from pathlib import Path +from datetime import datetime, timezone, timedelta +from dotenv import load_dotenv +import apprise + +# Load environment variables +load_dotenv() + +TAILSCALE_API_KEY = os.getenv('TAILSCALE_API_KEY') +TAILSCALE_TAILNET = os.getenv('TAILSCALE_TAILNET') +APPRISE_URLS = os.getenv('APPRISE_URLS', '').split(',') +CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '60')) # seconds +STATE_FILE = os.getenv('STATE_FILE', '/data/device_state.json') +ONLINE_THRESHOLD_SECONDS = int(os.getenv('ONLINE_THRESHOLD_SECONDS', '60')) # Consider online if seen within X seconds + +def load_previous_state(): + """Load the previous device state from file""" + state_path = Path(STATE_FILE) + if state_path.exists(): + try: + with open(state_path, 'r') as f: + return json.load(f) + except Exception as e: + print(f"Error loading state file: {e}") + return {} + +def save_current_state(state): + """Save the current device state to file""" + state_path = Path(STATE_FILE) + state_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(state_path, 'w') as f: + json.dump(state, f, indent=2) + except Exception as e: + print(f"Error saving state file: {e}") + +def get_tailscale_devices(): + """Fetch current device status from Tailscale API""" + url = f"https://api.tailscale.com/api/v2/tailnet/{TAILSCALE_TAILNET}/devices" + headers = { + 'Authorization': f'Bearer {TAILSCALE_API_KEY}' + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + data = response.json() + + # Debug: Print raw device data + if os.getenv('DEBUG') == 'true': + print("\n=== DEBUG: Raw API Response ===") + for device in data.get('devices', [])[:3]: # Print first 3 devices as sample + print(f"Device: {device.get('name', device.get('hostname'))}") + print(f" - id: {device.get('id')}") + print(f" - online: {device.get('online')}") + print(f" - lastSeen: {device.get('lastSeen')}") + print(f" - expires: {device.get('expires')}") + print("================================\n") + + return data + except requests.exceptions.RequestException as e: + print(f"Error fetching Tailscale devices: {e}") + return None + +def is_device_online(device): + """Determine if a device is actually online based on lastSeen timestamp""" + last_seen = device.get('lastSeen') + if not last_seen: + return False + + try: + # Parse the lastSeen timestamp + last_seen_time = datetime.fromisoformat(last_seen.replace('Z', '+00:00')) + now = datetime.now(timezone.utc) + + # Calculate time difference + time_diff = now - last_seen_time + + # Consider online if seen within threshold + is_online = time_diff < timedelta(seconds=ONLINE_THRESHOLD_SECONDS) + + if os.getenv('DEBUG') == 'true': + print(f" {device.get('name')}: last seen {time_diff.total_seconds():.1f}s ago -> {'ONLINE' if is_online else 'OFFLINE'}") + + return is_online + except Exception as e: + print(f"Error parsing lastSeen for {device.get('name')}: {e}") + return False + +def send_notification(title, body): + """Send notification via Apprise""" + apobj = apprise.Apprise() + + # Add all configured notification services + for url in APPRISE_URLS: + url = url.strip() + if url: + apobj.add(url) + + if len(apobj) == 0: + print("No Apprise URLs configured!") + return + + try: + apobj.notify( + title=title, + body=body, + ) + print(f"āœ“ Notification sent: {title}") + if os.getenv('DEBUG') == 'true': + print(f" Body preview: {body[:100]}...") + except Exception as e: + print(f"āœ— Error sending notification: {e}") + +def send_initial_status(): + """Send initial status report of all devices""" + print("Fetching initial device status...") + data = get_tailscale_devices() + if not data: + print("Failed to fetch initial device status") + return + + online_devices = [] + offline_devices = [] + + if os.getenv('DEBUG') == 'true': + print("\n=== Checking device online status ===") + + for device in data.get('devices', []): + device_name = device.get('name') or device.get('hostname', 'Unknown') + is_online = is_device_online(device) + + if is_online: + online_devices.append(device_name) + else: + offline_devices.append(device_name) + + if os.getenv('DEBUG') == 'true': + print("=====================================\n") + + # Build status message + total_devices = len(online_devices) + len(offline_devices) + + title = "šŸ“Š Tailscale Initial Status Report" + body_parts = [ + f"Total Devices: {total_devices}", + f"", + f"🟢 Online ({len(online_devices)}):", + ] + + if online_devices: + for device in sorted(online_devices): + body_parts.append(f" • {device}") + else: + body_parts.append(" (none)") + + body_parts.append("") + body_parts.append(f"šŸ”“ Offline ({len(offline_devices)}):") + + if offline_devices: + for device in sorted(offline_devices): + body_parts.append(f" • {device}") + else: + body_parts.append(" (none)") + + body_parts.append("") + body_parts.append(f"šŸ” Now monitoring for changes (checking every {CHECK_INTERVAL}s)...") + + body = "\n".join(body_parts) + + send_notification(title, body) + print(f"Initial status: {len(online_devices)} online, {len(offline_devices)} offline") + + # Save initial state + current_devices = {} + for device in data.get('devices', []): + device_id = device['id'] + current_devices[device_id] = { + 'name': device.get('name') or device.get('hostname', 'Unknown'), + 'online': is_device_online(device), + 'lastSeen': device.get('lastSeen', ''), + } + save_current_state(current_devices) + +def check_devices(): + """Check for device status changes and send notifications""" + data = get_tailscale_devices() + if not data: + return + + current_devices = {} + + # Build current state map + for device in data.get('devices', []): + device_id = device['id'] + current_devices[device_id] = { + 'name': device.get('name') or device.get('hostname', 'Unknown'), + 'online': is_device_online(device), + 'lastSeen': device.get('lastSeen', ''), + } + + # Load previous state + previous_state = load_previous_state() + + # Check for changes + changes = [] + + # Check for new devices or status changes + for device_id, device in current_devices.items(): + prev = previous_state.get(device_id) + + if not prev: + # New device detected + changes.append({ + 'device': device['name'], + 'status': 'online' if device['online'] else 'offline', + 'type': 'new' + }) + elif prev['online'] != device['online']: + # Status changed + changes.append({ + 'device': device['name'], + 'status': 'online' if device['online'] else 'offline', + 'type': 'changed' + }) + + # Check for removed devices + for device_id, device in previous_state.items(): + if device_id not in current_devices: + changes.append({ + 'device': device['name'], + 'status': 'removed', + 'type': 'removed' + }) + + # Send notifications for changes + for change in changes: + if change['type'] == 'new': + emoji = '🟢' if change['status'] == 'online' else 'šŸ”“' + title = f"šŸ†• New Tailscale Device" + body = f"{change['device']} is {change['status']}" + elif change['type'] == 'removed': + title = "šŸ—‘ļø Tailscale Device Removed" + body = f"{change['device']} was removed from the network" + else: + emoji = '🟢' if change['status'] == 'online' else 'šŸ”“' + title = f"{emoji} Device {change['status'].title()}" + body = f"{change['device']} is now {change['status']}" + + send_notification(title, body) + + # Save current state + save_current_state(current_devices) + + if changes: + print(f"āœ“ Processed {len(changes)} device change(s)") + else: + print(f"āœ“ No changes detected (checked {len(current_devices)} devices)") + +def main(): + """Main monitoring loop""" + print("=" * 50) + print("Tailscale Device Monitor Starting...") + print("=" * 50) + print(f"Monitoring tailnet: {TAILSCALE_TAILNET}") + print(f"Check interval: {CHECK_INTERVAL} seconds") + print(f"Online threshold: {ONLINE_THRESHOLD_SECONDS} seconds") + print(f"Apprise URLs configured: {len([u for u in APPRISE_URLS if u.strip()])}") + print(f"Debug mode: {os.getenv('DEBUG', 'false')}") + print("=" * 50) + + if not TAILSCALE_API_KEY or not TAILSCALE_TAILNET: + print("ERROR: TAILSCALE_API_KEY and TAILSCALE_TAILNET must be set!") + return + + # Send initial status report + send_initial_status() + + # Wait before starting monitoring loop + print(f"\nWaiting {CHECK_INTERVAL} seconds before first check...") + time.sleep(CHECK_INTERVAL) + + print("\nšŸ” Starting monitoring loop...\n") + + while True: + try: + check_devices() + except Exception as e: + print(f"āœ— Error in monitoring loop: {e}") + send_notification("āš ļø Tailscale Monitor Error", str(e)) + + time.sleep(CHECK_INTERVAL) + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd4683e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests +apprise +python-dotenv