Skip to content

About

Know if someone is using your Mac while you're away — Telegram alerts, plus a webcam photo & screenshot when it joins an unknown network

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

net-notify — Know if someone is using your Mac while you're away

English | 繁體中文

Install it on your own Mac. Every time that Mac connects to a network, it sends you a Telegram message with where it connected from; and if the network is not on your whitelist, it also takes a photo with the front camera of whoever is sitting at the machine, grabs a screenshot of what's on screen, and sends both to you.

Use cases

The core problem: when your computer isn't in your hands, you have no idea what's happening to it. Apple's Find My gives you a dot on a map. It won't tell you that someone is using it, and it certainly won't show you who they are or what they're looking at. That's the gap net-notify fills.

Situation What you get
Laptop stolen or lost The moment the thief boots it and joins their home or café Wi-Fi → you get "🚨 Unknown network" + a front-camera photo (who) + a screenshot (what they're doing) + the real egress IP / city / ISP (evidence police can act on with a lawful request to the ISP)
Computer left at home or the office while you're out You're notified if someone moves it and plugs it into a different network; on networks you already know, it stays quiet
You suspect someone touches your machine when you're not there Send /snap from Telegram to remotely take a front-camera photo plus a screenshot on demand
You just want to know where the machine is Every connection logs location, ISP and public IP — effectively a connection trail

How it decides what's "unknown": you add your own networks (home, office, your own phone hotspot) to a whitelist. Networks on the list are tagged ✅ known and trigger no capture; anything off the list is tagged 🚨 unknown network and captures evidence automatically. The whitelist can be edited straight from Telegram with /add — no need to be at the computer.

What it deliberately cannot do (so there are no surprises):

  • If the thief never goes online, or just shuts the machine down / wipes it, there is no signal at all — that's inherent to "network connection is the trigger".
  • It's a LaunchAgent, so it only runs after someone logs into the account. Sitting at the login screen triggers nothing.
  • macOS always lights the green camera indicator when the camera is active. This cannot be hidden, by design.
  • This is a theft-recovery tool for your own computer. Installing it on someone else's machine to spy on them is illegal. Don't.

Features

  • Triggers on network state changes (joining Wi-Fi, switching networks, reconnecting after wake, logging in)
  • Collects and reports: time, hostname, interface, Wi-Fi (SSID/BSSID), local IP, gateway, VPN status, public IP, geolocation, ISP
  • Only fires on an actual reconnect: a different network, a reconnect after being offline, or the first connection after a reboot. Staying on the same network doesn't spam you.
  • Connectivity check: if the machine isn't really online yet (or is stuck behind a captive portal), the script retries and then gives up rather than sending noise.
  • Automatic photo + screenshot on unknown networks: joins a network that isn't whitelisted → front-camera photo and a screenshot of the current screen, both sent to Telegram (see the section below)
  • Real egress IP, bypassing VPN: even when a VPN is up, it tries to get the true outbound IP / location / ISP — that's the part that's actually traceable
  • Remote control: manage the whitelist and trigger captures on demand from Telegram
  • Near-zero dependencies at the core: plain bash + curl + built-in macOS commands. The camera feature additionally needs imagesnap (brew install imagesnap).

Layout

~/net-notify/
├── config.sh                          # token + chat_id + capture toggle (holds secrets, mode 600)
├── known_networks.conf                # whitelist of your own networks
├── net-notify.sh                      # main connection-notification script (mode 700)
├── capture.sh                         # front-camera + screenshot module (sourced by the two above)
├── tg-listener.sh                     # Telegram command listener: whitelist + manual capture (mode 700)
├── com.yen.netnotify.plist            # notification LaunchAgent (only active once copied into place)
├── com.yen.netnotify.listener.plist   # command listener LaunchAgent (resident, KeepAlive)
├── README.md                          # this file (English)
├── README.zh-TW.md                    # Traditional Chinese version
└── state/                             # created at runtime
    ├── last.txt                       # connection state (last network fingerprint + timestamp + state)
    ├── net-notify.log                 # notification script log
    ├── tg-listener.log                # command listener log
    ├── tg_offset.txt                  # Telegram getUpdates offset (avoids reprocessing)
    ├── stdout.log / stderr.log        # launchd output for the notifier
    ├── listener-stdout.log / listener-stderr.log  # launchd output for the listener
    ├── capture.log                    # capture module log
    └── captures/                      # scratch dir for captures (deleted after upload; normally empty)

The LaunchAgent copy that actually runs lives at ~/Library/LaunchAgents/com.yen.netnotify.plist (the location macOS requires). If you edit the plist in the project, copy it over again and reload it.

How it works

  1. ~/Library/LaunchAgents/com.yen.netnotify.plist uses WatchPaths on the system files below. macOS rewrites them whenever the network changes, which is what fires the script:
    • /etc/resolv.conf
    • /Library/Preferences/SystemConfiguration/com.apple.airport.preferences.plist
    • /Library/Preferences/SystemConfiguration/NetworkInterfaces.plist
    • RunAtLoad is also set, so it runs once at login.
  2. Once triggered, net-notify.sh:
    • retries curl ipinfo.io to confirm it really has internet; if not, it gives up without sending
    • reads the SSID / local IP from the physical Wi-Fi device directly (found via networksetup), so a VPN tunnel interface can't mask it
    • flags VPN status by checking whether the default route goes through utun*/ppp*/ipsec*
    • tracks connection state using SSID|BSSID|local IP|gateway as a fingerprint, sending only on reconnect
    • sends via the Telegram Bot API sendMessage

Configuration

Edit ~/net-notify/config.sh (changes take effect on the next trigger — no reload needed):

Variable Meaning
TG_BOT_TOKEN Telegram bot token (from @BotFather)
TG_CHAT_ID Your own numeric chat_id (not an @username)
CAPTURE_ON_UNKNOWN Auto photo + screenshot on unknown networks (1 = on, 0 = off; default on). Manual /snap ignores this.

First-time setup: cp config.sh.example config.sh && chmod 600 config.sh, then fill in your values.

Getting your chat_id

Send your bot any message, then run:

curl -s "https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates" | python3 -m json.tool

Look for result[].message.chat.id (a string of digits).

Install / enable

Both .plist files contain the placeholder path /Users/YOUR_USERNAME/net-notify (launchd doesn't expand ~, only absolute paths). Before copying them into place, change ProgramArguments, StandardOutPath and StandardErrorPath to this project's real absolute path on your machine.

cp ~/net-notify/com.yen.netnotify.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yen.netnotify.plist
launchctl enable gui/$(id -u)/com.yen.netnotify

Everyday commands

# edit config
open -e ~/net-notify/config.sh

# tail the log
tail -f ~/net-notify/state/net-notify.log

# run once manually (clear the connection-state file first so it definitely sends)
rm -f ~/net-notify/state/last.txt && bash ~/net-notify/net-notify.sh

# pause
launchctl bootout gui/$(id -u)/com.yen.netnotify

# resume
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yen.netnotify.plist

# check whether it's running
launchctl print gui/$(id -u)/com.yen.netnotify | grep state

# remove completely
launchctl bootout gui/$(id -u)/com.yen.netnotify
rm ~/Library/LaunchAgents/com.yen.netnotify.plist
rm -rf ~/net-notify

Known-network whitelist / unknown-network detection

The point is to answer "is this one of my own networks?". On the list → tagged ✅ known; not on it → tagged 🚨 unknown network. This filters out your own networks so your attention goes to the ones that matter (theft, or finding out who's using the machine).

The config file is ~/net-notify/known_networks.conf, one label|field|value per line:

Field Meaning Reliability
gwmac Gateway MAC address ⭐⭐⭐ Best: a VPN can't mask it, no location permission needed, and a physical router's MAC never changes
gwip Gateway IP ⭐⭐ Second best: every iPhone hotspot is 172.20.10.1, so it can't tell your phone from anyone else's

A single matching line is enough to mark the network known. Example:

Home fiber|gwmac|aa:bb:cc:dd:ee:ff
My phone hotspot|gwmac|11:22:33:44:55:66

How to know what to put in: every notification includes the current Gateway and its MAC — just copy those into the whitelist. (Next time you're home on your own Wi-Fi, read the gateway MAC off the notification and add a line Home fiber|gwmac|<that MAC>.)

Why gateway data is the only reliable identifier

Two platform limitations rule out everything else:

  • With a VPN up, public IP / geolocation / ISP / DNS all reflect the VPN exit, not the real network → unusable.
  • SSID / BSSID need location permission on macOS 14+, which a background launchd script doesn't have (you get <redacted>) → unusable.
  • Gateway IP/MAC sits below the VPN and needs no location permission — the only identifier immune to both.

Anti-theft note: if you whitelist a hotspot by gwip|172.20.10.1, a thief using their own iPhone hotspot is also matched as known. For strict anti-theft use gwmac bound to your own phone instead. The tradeoff: iOS hotspot MACs rotate occasionally, so you may sometimes be flagged as unknown on your own network — erring on the side of capturing too much.

Managing the whitelist from Telegram (command listener)

tg-listener.sh is a resident long-polling listener that lets you send commands from Telegram to edit the whitelist, instead of hand-editing known_networks.conf. It only accepts messages from the TG_CHAT_ID in config.sh; anything from anyone else is ignored.

Command Effect
/list List the current whitelist (numbered)
/add <nickname> Add the network you're on right now under that nickname (grabs the gateway MAC; falls back to gateway IP only if the MAC is unavailable)
/rename <number or nickname> <new nickname> Rename an entry without re-adding it
/remove <number or nickname> Remove an entry (numbers come from /list)
/snap Take a front-camera photo + screenshot right now and send them to Telegram
/photo Front-camera photo only
/screen Screenshot only
/help Show command help

Typical flow: you join a new network → get the "🚨 unknown network" alert → send /add Office WiFi to the bot from your phone → next time you're on it, it's tagged ✅ known. Since the listener runs on the Mac itself, "the current network" that /add captures is whatever that Mac is connected to at that moment.

Install / enable the listener

cp ~/net-notify/com.yen.netnotify.listener.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yen.netnotify.listener.plist
launchctl enable gui/$(id -u)/com.yen.netnotify.listener

Everyday listener commands

# tail the command log
tail -f ~/net-notify/state/tg-listener.log

# test a command locally without going through Telegram (the reply still lands in your TG)
bash ~/net-notify/tg-listener.sh handle '/list'

# check whether it's running
launchctl print gui/$(id -u)/com.yen.netnotify.listener | grep state

# pause / resume
launchctl bootout gui/$(id -u)/com.yen.netnotify.listener
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yen.netnotify.listener.plist

Note: a bot can only have one getUpdates consumer at a time. Once the listener is running, don't also run getUpdates by hand (e.g. the "Getting your chat_id" snippet above) or the two will steal each other's messages.

Dependency: JSON parsing uses python3 (on macOS that means Command Line Tools). The rest of the project is plain bash + curl.

Automatic photo + screenshot on unknown networks

When an unknown network is detected (not whitelisted), net-notify.sh calls capture_and_send from capture.sh: one front-camera shot via imagesnap, one screenshot via screencapture -x (silent), both sent to Telegram along with the reason (gateway / MAC). You can also trigger it any time from Telegram with /snap, /photo or /screen.

The evidence is the pair of images in Telegram — the message is on your phone, so it survives even if the computer is powered off or wiped. Local image files are deleted immediately after a successful upload (saves space, and leaves nothing for the other party to find). The capture process is logged to state/capture.log.

Toggle

Set CAPTURE_ON_UNKNOWN=0 in config.sh to disable automatic capture on unknown networks (on by default; 1 or unset means on). Manual commands like /snap are unaffected.

Dependencies and permissions (TCC)

  • Camera: needs imagesnap (brew install imagesnap). The launchd environment has a minimal PATH, so capture.sh resolves absolute paths such as /opt/homebrew/bin/imagesnap itself.
  • TCC authorization: photos need Camera permission and screenshots need Screen Recording permission, and the grant applies to the process actually running the script (Terminal during interactive testing; the launchd agent when resident). Without it the capture fails, capture.sh reports "⚠️ … capture failed" and logs to capture.log. The first launchd-triggered run makes macOS raise the permission prompt — approve it under System Settings → Privacy & Security → Camera / Screen Recording.
  • Green indicator light: macOS always lights the green LED when the camera is on. It cannot be hidden (by design).
  • Privacy / false positives: a rotated iOS hotspot MAC can mark your own network as unknown and photograph you — erring on the side of capturing too much.

Testing

# test locally (photos/screenshots really are sent to your Telegram)
bash ~/net-notify/tg-listener.sh handle '/snap'

# test just the underlying tools, no Telegram
imagesnap -w 1 /tmp/cam.jpg && open /tmp/cam.jpg
screencapture -x /tmp/shot.png && open /tmp/shot.png

Verifying that the trigger works

Nothing is re-sent while you stay on the same network without dropping the connection (system events like DNS updates or DHCP renewals fire the script but don't send). It sends when:

  • you switch to a different network
  • you come back online after being offline long enough (the script ran, failed to reach the internet, and recorded the offline marker)
  • it's the first connection after a reboot

To test it for real: switch to another network (your phone hotspot, say), or turn Wi-Fi off for about a minute so the script records the offline marker, then reconnect.

Known limitations / caveats

  • Real SSID: on macOS 14+ only processes with location permission can see the real SSID, which a background launchd script generally doesn't have, so this field may always be blank. That's a platform privacy restriction, not a bug in the script. No other field is affected.
  • Public IP / location: with a VPN up, "🌍 public IP" reflects the VPN exit, not the real ISP. The VPN field says so.
  • Real egress IP (bypassing VPN): the script additionally uses curl --interface <physical NIC> to go around the tunnel and fetch the real outbound IP / location / ISP. When it differs from the VPN exit, the message gains three extra 🛰️ real egress lines. This is the traceable lead — the one police can follow with a lawful request to the ISP or carrier.
    • When it fails: if the VPN client's kill switch is enabled, non-tunnel traffic is blocked, this bypass fails and the real IP can't be obtained.
    • Tracing still has caveats: if the other party is running their own VPN, what you capture is their VPN's IP; and on mobile networks, CGNAT means a precise timestamp is required to identify a subscriber.
  • Captive-portal public Wi-Fi: before you pass the login page there's no internet, so the script retries, gives up and sends nothing, then fires once you're actually online.
  • Security: config.sh holds your bot token and is excluded by .gitignore. Never force it into version control or a cloud-sync folder — same goes for known_networks.conf, which contains your home gateway MAC. If the token leaks, use /revoke with @BotFather for a new one and update config.sh.
  • Boot vs login: this is a LaunchAgent, so it only becomes active after login (fine for a personal laptop). To monitor from boot before anyone logs in, it would have to become a LaunchDaemon (different privilege model).

About

Know if someone is using your Mac while you're away — Telegram alerts, plus a webcam photo & screenshot when it joins an unknown network

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages