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.
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.
- 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 needsimagesnap(brew install imagesnap).
~/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.
~/Library/LaunchAgents/com.yen.netnotify.plistusesWatchPathson 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.plistRunAtLoadis also set, so it runs once at login.
- Once triggered,
net-notify.sh:- retries
curl ipinfo.ioto 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|gatewayas a fingerprint, sending only on reconnect - sends via the Telegram Bot API
sendMessage
- retries
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.
Send your bot any message, then run:
curl -s "https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates" | python3 -m json.toolLook for result[].message.chat.id (a string of digits).
Both
.plistfiles contain the placeholder path/Users/YOUR_USERNAME/net-notify(launchd doesn't expand~, only absolute paths). Before copying them into place, changeProgramArguments,StandardOutPathandStandardErrorPathto 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# 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-notifyThe 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>.)
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 usegwmacbound 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.
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.
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# 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.plistNote: a bot can only have one
getUpdatesconsumer at a time. Once the listener is running, don't also rungetUpdatesby 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.
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.
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.
- Camera: needs
imagesnap(brew install imagesnap). The launchd environment has a minimalPATH, socapture.shresolves absolute paths such as/opt/homebrew/bin/imagesnapitself. - 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.shreports "⚠️ … capture failed" and logs tocapture.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.
# 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.pngNothing 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.
- 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 egresslines. 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.shholds your bot token and is excluded by.gitignore. Never force it into version control or a cloud-sync folder — same goes forknown_networks.conf, which contains your home gateway MAC. If the token leaks, use/revokewith @BotFather for a new one and updateconfig.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).