n0rma-sec 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- n0rma_sec-0.3.1/LICENSE +21 -0
- n0rma_sec-0.3.1/PKG-INFO +67 -0
- n0rma_sec-0.3.1/README.md +50 -0
- n0rma_sec-0.3.1/n0rma/__init__.py +1 -0
- n0rma_sec-0.3.1/n0rma/__main__.py +2 -0
- n0rma_sec-0.3.1/n0rma/cli.py +352 -0
- n0rma_sec-0.3.1/n0rma/core.py +197 -0
- n0rma_sec-0.3.1/n0rma/det_ble.py +173 -0
- n0rma_sec-0.3.1/n0rma/det_host.py +161 -0
- n0rma_sec-0.3.1/n0rma/det_lan.py +164 -0
- n0rma_sec-0.3.1/n0rma/det_sdr.py +100 -0
- n0rma_sec-0.3.1/n0rma/det_wifi.py +139 -0
- n0rma_sec-0.3.1/n0rma/oui.py +82 -0
- n0rma_sec-0.3.1/n0rma/remoteid.py +89 -0
- n0rma_sec-0.3.1/n0rma/web.py +232 -0
- n0rma_sec-0.3.1/n0rma_sec.egg-info/PKG-INFO +67 -0
- n0rma_sec-0.3.1/n0rma_sec.egg-info/SOURCES.txt +22 -0
- n0rma_sec-0.3.1/n0rma_sec.egg-info/dependency_links.txt +1 -0
- n0rma_sec-0.3.1/n0rma_sec.egg-info/entry_points.txt +3 -0
- n0rma_sec-0.3.1/n0rma_sec.egg-info/requires.txt +1 -0
- n0rma_sec-0.3.1/n0rma_sec.egg-info/top_level.txt +1 -0
- n0rma_sec-0.3.1/pyproject.toml +29 -0
- n0rma_sec-0.3.1/setup.cfg +4 -0
- n0rma_sec-0.3.1/tests/test_parsers.py +425 -0
n0rma_sec-0.3.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Davis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
n0rma_sec-0.3.1/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: n0rma-sec
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Home counter-surveillance detector for Linux: Remote ID drones, Bluetooth trackers, unknown devices, RF.
|
|
5
|
+
Author: Ryan Davis
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sloppytopp/n0rma
|
|
8
|
+
Project-URL: Issues, https://github.com/sloppytopp/n0rma/issues
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: bleak>=0.21
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# N0RMA
|
|
19
|
+
|
|
20
|
+
_Formerly called Homewatch._
|
|
21
|
+
|
|
22
|
+
Home counter-surveillance **detector** for **Linux** (BlueZ + NetworkManager + `iw`). Detect-only: no jamming, no spoofing.
|
|
23
|
+
Run: `./n0rma.sh setup` once, then `./n0rma.sh run` -> http://127.0.0.1:8777
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## What's new in 0.2
|
|
28
|
+
- Dark, muted, low-glare dashboard (no flashing alerts; every status is word + icon + color) with a **Night** switch (dim red on black)
|
|
29
|
+
- A plain-language banner ("All clear" / "Keeping an eye on something" / "Needs your attention") with a breathing dot and live scan ages, so you can see it is working
|
|
30
|
+
- **"This is mine"** on trackers: your own Tile/AirTag stops flagging and shows as yours
|
|
31
|
+
- Sister project: [n0rma-android](https://github.com/sloppytopp/n0rma-android) (public, with signed APKs on its Releases page) - same detection on a phone, with tap-to-find, room sweeps and survey exports
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
`pip install n0rma-sec` (the command it installs is `n0rma`) (or from source below). System packages are still needed:
|
|
35
|
+
`sudo apt install ieee-data nmap iw network-manager bluez` (the vendor database from `ieee-data`/`nmap` is required for
|
|
36
|
+
camera/drone vendor detection - N0RMA warns at startup if it is missing), then
|
|
37
|
+
`python3 -m venv .venv --system-site-packages && .venv/bin/pip install bleak`.
|
|
38
|
+
Optional RF module: `sudo apt install rtl-sdr` + an RTL-SDR dongle (24 MHz-1.7 GHz only; not 2.4/5.8 GHz).
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
| Command | What |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `setup` | first-run walkthrough: your Wi-Fi, home position, which devices are yours |
|
|
44
|
+
| `run [--lan]` | all detectors + dashboard (type `b`+Enter to log a sensor beep) |
|
|
45
|
+
| `scan` | one-shot scan |
|
|
46
|
+
| `beep [--ago MIN]` / `report` | log sensor beeps / line them up against detections (with background baseline) |
|
|
47
|
+
| `devices` / `trust all\|<mac>` | LAN inventory; the first scan auto-trusts everything - **review it** |
|
|
48
|
+
| `find <BLE addr>` | hot/cold locator |
|
|
49
|
+
| `home <lat> <lon>` | home position for the drone map (west longitude is negative) |
|
|
50
|
+
| `ntfy setup\|test\|on\|off` | phone alerts (generic text only, via ntfy.sh) |
|
|
51
|
+
| `selftest` | unit tests |
|
|
52
|
+
|
|
53
|
+
## Read this first
|
|
54
|
+
- **This is not a guarantee of safety.** A quiet dashboard means "nothing seen by these radios", not "nothing is there".
|
|
55
|
+
Blind spots: drones without Remote ID, SD-card-only cameras, cellular/GPS trackers, anything on 2.4/5.8 GHz video.
|
|
56
|
+
- **Remote ID is unauthenticated.** Anyone can broadcast a fake one, so a red drone alert means a broadcast *claims* a
|
|
57
|
+
drone. Position and operator location are whatever the sender says. Operator coordinates are shown live and are
|
|
58
|
+
**never written to disk**; events are pruned after 30 days.
|
|
59
|
+
- **Do not interfere with a drone** (shooting at, jamming or spoofing one is a federal crime). Report it to law
|
|
60
|
+
enforcement or the FAA.
|
|
61
|
+
- **The radar is a rough estimate.** Signal strength gives only a crude distance, badly distorted indoors, and no direction.
|
|
62
|
+
- **If you find something:** don't touch or move it, photograph it where it is, note the time, and contact local law
|
|
63
|
+
enforcement. A tracker you don't own: Apple/Android show unknown-tracker alerts and can help identify it.
|
|
64
|
+
- **`--lan` is plain HTTP.** Anyone sniffing your Wi-Fi can see the private link/token. Use it only on a network you trust.
|
|
65
|
+
Localhost mode only accepts `Host: 127.0.0.1/localhost` (blocks DNS rebinding) and state-changing requests need an
|
|
66
|
+
`X-Homewatch` header.
|
|
67
|
+
- Your Wi-Fi name, home position, tokens and the ntfy topic live in `~/.local/share/n0rma/config.json` (mode 600), never in the code.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# N0RMA
|
|
2
|
+
|
|
3
|
+
_Formerly called Homewatch._
|
|
4
|
+
|
|
5
|
+
Home counter-surveillance **detector** for **Linux** (BlueZ + NetworkManager + `iw`). Detect-only: no jamming, no spoofing.
|
|
6
|
+
Run: `./n0rma.sh setup` once, then `./n0rma.sh run` -> http://127.0.0.1:8777
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## What's new in 0.2
|
|
11
|
+
- Dark, muted, low-glare dashboard (no flashing alerts; every status is word + icon + color) with a **Night** switch (dim red on black)
|
|
12
|
+
- A plain-language banner ("All clear" / "Keeping an eye on something" / "Needs your attention") with a breathing dot and live scan ages, so you can see it is working
|
|
13
|
+
- **"This is mine"** on trackers: your own Tile/AirTag stops flagging and shows as yours
|
|
14
|
+
- Sister project: [n0rma-android](https://github.com/sloppytopp/n0rma-android) (public, with signed APKs on its Releases page) - same detection on a phone, with tap-to-find, room sweeps and survey exports
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
`pip install n0rma-sec` (the command it installs is `n0rma`) (or from source below). System packages are still needed:
|
|
18
|
+
`sudo apt install ieee-data nmap iw network-manager bluez` (the vendor database from `ieee-data`/`nmap` is required for
|
|
19
|
+
camera/drone vendor detection - N0RMA warns at startup if it is missing), then
|
|
20
|
+
`python3 -m venv .venv --system-site-packages && .venv/bin/pip install bleak`.
|
|
21
|
+
Optional RF module: `sudo apt install rtl-sdr` + an RTL-SDR dongle (24 MHz-1.7 GHz only; not 2.4/5.8 GHz).
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
| Command | What |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `setup` | first-run walkthrough: your Wi-Fi, home position, which devices are yours |
|
|
27
|
+
| `run [--lan]` | all detectors + dashboard (type `b`+Enter to log a sensor beep) |
|
|
28
|
+
| `scan` | one-shot scan |
|
|
29
|
+
| `beep [--ago MIN]` / `report` | log sensor beeps / line them up against detections (with background baseline) |
|
|
30
|
+
| `devices` / `trust all\|<mac>` | LAN inventory; the first scan auto-trusts everything - **review it** |
|
|
31
|
+
| `find <BLE addr>` | hot/cold locator |
|
|
32
|
+
| `home <lat> <lon>` | home position for the drone map (west longitude is negative) |
|
|
33
|
+
| `ntfy setup\|test\|on\|off` | phone alerts (generic text only, via ntfy.sh) |
|
|
34
|
+
| `selftest` | unit tests |
|
|
35
|
+
|
|
36
|
+
## Read this first
|
|
37
|
+
- **This is not a guarantee of safety.** A quiet dashboard means "nothing seen by these radios", not "nothing is there".
|
|
38
|
+
Blind spots: drones without Remote ID, SD-card-only cameras, cellular/GPS trackers, anything on 2.4/5.8 GHz video.
|
|
39
|
+
- **Remote ID is unauthenticated.** Anyone can broadcast a fake one, so a red drone alert means a broadcast *claims* a
|
|
40
|
+
drone. Position and operator location are whatever the sender says. Operator coordinates are shown live and are
|
|
41
|
+
**never written to disk**; events are pruned after 30 days.
|
|
42
|
+
- **Do not interfere with a drone** (shooting at, jamming or spoofing one is a federal crime). Report it to law
|
|
43
|
+
enforcement or the FAA.
|
|
44
|
+
- **The radar is a rough estimate.** Signal strength gives only a crude distance, badly distorted indoors, and no direction.
|
|
45
|
+
- **If you find something:** don't touch or move it, photograph it where it is, note the time, and contact local law
|
|
46
|
+
enforcement. A tracker you don't own: Apple/Android show unknown-tracker alerts and can help identify it.
|
|
47
|
+
- **`--lan` is plain HTTP.** Anyone sniffing your Wi-Fi can see the private link/token. Use it only on a network you trust.
|
|
48
|
+
Localhost mode only accepts `Host: 127.0.0.1/localhost` (blocks DNS rebinding) and state-changing requests need an
|
|
49
|
+
`X-Homewatch` header.
|
|
50
|
+
- Your Wi-Fi name, home position, tokens and the ntfy topic live in `~/.local/share/n0rma/config.json` (mode 600), never in the code.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .core import DOMAINS, DB, Engine, load_config, save_config, new_token, clean
|
|
7
|
+
|
|
8
|
+
ICON = {"ok": "OK ", "watch": "WATCH", "alert": "ALERT", "off": "off "}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_run(a):
|
|
12
|
+
from . import det_ble, det_host, det_lan, det_sdr, det_wifi, web
|
|
13
|
+
eng = Engine()
|
|
14
|
+
det_ble.BLEDetector(eng).start()
|
|
15
|
+
if not a.no_wifi:
|
|
16
|
+
det_wifi.WifiDetector(eng).start()
|
|
17
|
+
det_lan.LanDetector(eng).start()
|
|
18
|
+
det_host.HostDetector(eng, own_port=a.port).start()
|
|
19
|
+
det_sdr.SdrDetector(eng).start()
|
|
20
|
+
from . import oui
|
|
21
|
+
if oui.table_size() == 0:
|
|
22
|
+
print("WARNING: no MAC vendor database found - camera/drone vendor detection is OFF.\n"
|
|
23
|
+
" Install it: sudo apt install ieee-data nmap", flush=True)
|
|
24
|
+
eng.db.prune()
|
|
25
|
+
cfg = load_config()
|
|
26
|
+
if not cfg.get("my_ssids"): # learn which Wi-Fi is yours (first run) - never hardcoded
|
|
27
|
+
import subprocess
|
|
28
|
+
out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], capture_output=True, text=True).stdout
|
|
29
|
+
mine = sorted({l.split(":", 1)[1] for l in out.splitlines() if l.startswith("yes:") and l.split(":", 1)[1]})
|
|
30
|
+
if mine:
|
|
31
|
+
cfg["my_ssids"] = mine
|
|
32
|
+
save_config(cfg)
|
|
33
|
+
print(f"Learned your Wi-Fi name(s): {', '.join(mine)} (edit my_ssids in {__import__('n0rma.core', fromlist=['x']).CONFIG_PATH})")
|
|
34
|
+
token = None
|
|
35
|
+
if a.lan:
|
|
36
|
+
token = cfg.setdefault("dashboard_key", new_token())
|
|
37
|
+
save_config(cfg)
|
|
38
|
+
web.serve(eng, "0.0.0.0" if a.lan else "127.0.0.1", a.port, token)
|
|
39
|
+
print(f"N0RMA running. Dashboard: http://127.0.0.1:{a.port}")
|
|
40
|
+
if a.lan:
|
|
41
|
+
import subprocess
|
|
42
|
+
ip = subprocess.run("hostname -I", shell=True, capture_output=True, text=True).stdout.split()[0]
|
|
43
|
+
print(f"Phone (same Wi-Fi): http://{ip}:{a.port}/?k={token} <- keep this link private")
|
|
44
|
+
if a.lan:
|
|
45
|
+
print("NOTE: --lan is plain HTTP. Anyone sniffing your Wi-Fi could see the link/token. Use only on a network you trust.")
|
|
46
|
+
print(f"Phone alerts: {'ON' if cfg.get('ntfy', {}).get('enabled') else 'off (see: n0rma ntfy setup)'}\n"
|
|
47
|
+
"Type b + Enter when the sensor beeps. Ctrl-C to stop.", flush=True)
|
|
48
|
+
|
|
49
|
+
def keys():
|
|
50
|
+
for line in sys.stdin:
|
|
51
|
+
if line.strip().lower() in ("b", "beep"):
|
|
52
|
+
eng.beep("keyboard")
|
|
53
|
+
print(" -> beep logged", flush=True)
|
|
54
|
+
if sys.stdin.isatty():
|
|
55
|
+
threading.Thread(target=keys, daemon=True).start()
|
|
56
|
+
try:
|
|
57
|
+
while True:
|
|
58
|
+
time.sleep(60)
|
|
59
|
+
line = " | ".join(f"{d}:{eng.state[d]['level']}" for d in DOMAINS)
|
|
60
|
+
print(f"[{time.strftime('%H:%M')}] {line}", flush=True)
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
eng.stop.set()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_beep(a):
|
|
66
|
+
db = DB()
|
|
67
|
+
ts = time.time() - a.ago * 60
|
|
68
|
+
db.exec("INSERT INTO beeps(ts,note) VALUES(?,?)", (ts, " ".join(a.note)))
|
|
69
|
+
print(f"Beep logged at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_report(a):
|
|
73
|
+
db = DB()
|
|
74
|
+
since = time.time() - a.hours * 3600
|
|
75
|
+
beeps = db.query("SELECT * FROM beeps WHERE ts>? ORDER BY ts", (since,))
|
|
76
|
+
if not beeps:
|
|
77
|
+
print("No beeps logged in that window. Log them with: n0rma beep (or 'b' in `run`)")
|
|
78
|
+
hits = 0
|
|
79
|
+
for b in beeps:
|
|
80
|
+
near = db.query("SELECT * FROM events WHERE ts BETWEEN ? AND ? AND level IN ('alert','watch') "
|
|
81
|
+
"AND kind!='beep' ORDER BY ts", (b["ts"] - a.window * 60, b["ts"] + a.window * 60))
|
|
82
|
+
stamp = time.strftime("%a %m-%d %H:%M:%S", time.localtime(b["ts"]))
|
|
83
|
+
print(f"\nBEEP {stamp} {b['note'] or ''}")
|
|
84
|
+
if near:
|
|
85
|
+
hits += 1
|
|
86
|
+
for e in near:
|
|
87
|
+
print(f" {time.strftime('%H:%M:%S', time.localtime(e['ts']))} {e['level']:5} {e['domain']:7} {e['msg']}")
|
|
88
|
+
else:
|
|
89
|
+
print(f" nothing flagged within +/-{a.window} min")
|
|
90
|
+
if beeps:
|
|
91
|
+
print(f"\n== {hits}/{len(beeps)} beeps had a detection nearby. ==")
|
|
92
|
+
hrs = sorted(time.localtime(b['ts']).tm_hour for b in beeps)
|
|
93
|
+
print("Beep hours:", ", ".join(f"{h:02d}:xx" for h in hrs))
|
|
94
|
+
if hits == 0:
|
|
95
|
+
print("No drone, tracker, camera or RF event lines up with any beep -> sensor/animal/environment is the likely cause.")
|
|
96
|
+
if beeps:
|
|
97
|
+
span = max(1.0, (time.time() - min(since, beeps[0]["ts"])) / 60)
|
|
98
|
+
wins = max(1, int(span / (2 * a.window)))
|
|
99
|
+
busy = db.query("SELECT COUNT(DISTINCT CAST(ts/? AS INT)) n FROM events WHERE level IN ('alert','watch') "
|
|
100
|
+
"AND kind!='beep' AND ts>?", (a.window * 120, since))[0]["n"]
|
|
101
|
+
print(f"Baseline: detections were present in {busy} of ~{wins} {2*a.window:.0f}-minute windows overall. "
|
|
102
|
+
f"If that is close to {hits}/{len(beeps)}, the detections are background and prove nothing about the beeps.")
|
|
103
|
+
allev = db.query("SELECT level,COUNT(*) n FROM events WHERE ts>? GROUP BY level", (since,))
|
|
104
|
+
print("Events in window:", {e["level"]: e["n"] for e in allev})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_devices(a):
|
|
108
|
+
rows = DB().query("SELECT * FROM devices ORDER BY trusted, klass DESC, ip")
|
|
109
|
+
for d in rows:
|
|
110
|
+
t = "trusted " if d["trusted"] else "UNREVIEWED"
|
|
111
|
+
name = clean(d.get("label") or d.get("hostname") or "")
|
|
112
|
+
print(f"{t} {d['ip']:15} {d['mac']} {name[:22]:22} {d['klass']:8} {d['vendor'][:28]:28} ports:{d['ports'] or '-'}")
|
|
113
|
+
print("\nMark yours as trusted: n0rma trust all | n0rma trust <mac> | give one a name: n0rma name <mac> Missy iPhone")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_trust(a):
|
|
117
|
+
db = DB()
|
|
118
|
+
if a.mac == "all":
|
|
119
|
+
db.exec("UPDATE devices SET trusted=1")
|
|
120
|
+
else:
|
|
121
|
+
db.exec("UPDATE devices SET trusted=1 WHERE mac=?", (a.mac.lower(),))
|
|
122
|
+
print("ok")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_name(a):
|
|
126
|
+
label = clean(" ".join(a.label), 40)
|
|
127
|
+
DB().exec("UPDATE devices SET label=? WHERE mac=?", (label, a.mac.lower()))
|
|
128
|
+
print("ok" if label else "name cleared")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_ignore(a):
|
|
132
|
+
db = DB()
|
|
133
|
+
cur = set(db.kv_get("ble_ignore", []))
|
|
134
|
+
cur.add(a.addr.upper())
|
|
135
|
+
db.kv_set("ble_ignore", sorted(cur))
|
|
136
|
+
print("Ignoring BLE device", a.addr, "(note: AirTags rotate addresses; this only mutes this one address)")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_find(a):
|
|
140
|
+
import asyncio
|
|
141
|
+
from bleak import BleakScanner
|
|
142
|
+
target = a.addr.upper()
|
|
143
|
+
print(f"Hot/cold finder for {target}. Walk around; stronger bar = closer. Ctrl-C to stop.")
|
|
144
|
+
|
|
145
|
+
def cb(dev, adv):
|
|
146
|
+
if dev.address.upper() == target:
|
|
147
|
+
n = max(0, min(40, int((adv.rssi + 100) * 40 / 60)))
|
|
148
|
+
print(f"{adv.rssi:4} dBm |{'#' * n}", flush=True)
|
|
149
|
+
|
|
150
|
+
async def go():
|
|
151
|
+
async with BleakScanner(cb):
|
|
152
|
+
while True:
|
|
153
|
+
await asyncio.sleep(1)
|
|
154
|
+
try:
|
|
155
|
+
asyncio.run(go())
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_home(a):
|
|
161
|
+
cfg = load_config()
|
|
162
|
+
if a.lat is not None:
|
|
163
|
+
cfg["home"] = {"lat": a.lat, "lon": a.lon}
|
|
164
|
+
save_config(cfg)
|
|
165
|
+
print("Home location:", cfg.get("home") or "not set",
|
|
166
|
+
"\nTip: for exact position, long-press your house in Google/Apple Maps and copy the two numbers, then:"
|
|
167
|
+
"\n n0rma home <lat> <lon>")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_ntfy(a):
|
|
171
|
+
import urllib.request
|
|
172
|
+
cfg = load_config()
|
|
173
|
+
n = cfg.setdefault("ntfy", {})
|
|
174
|
+
if a.action == "setup":
|
|
175
|
+
n["topic"] = n.get("topic") or "n0rma-" + new_token()
|
|
176
|
+
n.setdefault("enabled", False)
|
|
177
|
+
save_config(cfg)
|
|
178
|
+
print(f"Topic created: {n['topic']}\n"
|
|
179
|
+
"1. Install the free 'ntfy' app (iOS App Store / Android Play Store).\n"
|
|
180
|
+
f"2. In the app tap + and subscribe to topic: {n['topic']} (server: ntfy.sh)\n"
|
|
181
|
+
"3. Then run: n0rma ntfy test (sends one test message)\n"
|
|
182
|
+
"4. Then run: n0rma ntfy on\n"
|
|
183
|
+
"Note: the topic name is the only 'password', so keep it private. Pushes are generic - never MACs or coordinates.")
|
|
184
|
+
return
|
|
185
|
+
if not n.get("topic"):
|
|
186
|
+
sys.exit("Run first: n0rma ntfy setup")
|
|
187
|
+
if a.action in ("on", "off"):
|
|
188
|
+
n["enabled"] = a.action == "on"
|
|
189
|
+
save_config(cfg)
|
|
190
|
+
print("Phone alerts", "ON" if n["enabled"] else "OFF")
|
|
191
|
+
elif a.action == "test":
|
|
192
|
+
req = urllib.request.Request(f"https://ntfy.sh/{n['topic']}", data=b"N0RMA test: phone alerts work.",
|
|
193
|
+
headers={"Title": "N0RMA"}, method="POST")
|
|
194
|
+
urllib.request.urlopen(req, timeout=10).read()
|
|
195
|
+
print("Test message sent. Check your phone.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_setup(a):
|
|
199
|
+
"""First-run walkthrough: your Wi-Fi, home position, and which devices on the network are yours."""
|
|
200
|
+
import socket
|
|
201
|
+
import subprocess
|
|
202
|
+
from . import det_lan
|
|
203
|
+
cfg = load_config()
|
|
204
|
+
print("N0RMA setup - takes about a minute.\n")
|
|
205
|
+
out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], capture_output=True, text=True).stdout
|
|
206
|
+
mine = sorted({l.split(":", 1)[1] for l in out.splitlines() if l.startswith("yes:") and l.split(":", 1)[1]})
|
|
207
|
+
if mine:
|
|
208
|
+
ok = input(f"1. Is your home Wi-Fi '{', '.join(mine)}'? [Y/n] ").strip().lower() != "n"
|
|
209
|
+
if ok:
|
|
210
|
+
cfg["my_ssids"] = mine
|
|
211
|
+
if "home" not in cfg:
|
|
212
|
+
print("\n2. Home position (optional, for the drone map). In Google/Apple Maps long-press your house and copy the")
|
|
213
|
+
print(" two numbers. West longitude is negative (e.g. 40.7128 -74.0060). Press Enter to skip.")
|
|
214
|
+
raw = input(" lat lon: ").split()
|
|
215
|
+
if len(raw) == 2:
|
|
216
|
+
try:
|
|
217
|
+
cfg["home"] = {"lat": float(raw[0]), "lon": float(raw[1])}
|
|
218
|
+
except ValueError:
|
|
219
|
+
print(" (couldn't read that - skipped)")
|
|
220
|
+
save_config(cfg)
|
|
221
|
+
print("\n3. Scanning your network for devices...")
|
|
222
|
+
eng = Engine(quiet=True)
|
|
223
|
+
det_lan.LanDetector(eng).scan()
|
|
224
|
+
pend = eng.db.query("SELECT * FROM devices WHERE trusted=0 ORDER BY ip")
|
|
225
|
+
if not pend:
|
|
226
|
+
print(" Nothing to review.")
|
|
227
|
+
for d in pend:
|
|
228
|
+
try:
|
|
229
|
+
name = socket.gethostbyaddr(d["ip"])[0]
|
|
230
|
+
except OSError:
|
|
231
|
+
name = ""
|
|
232
|
+
what = f"{d['ip']:15} {d['mac']} {d['vendor']} {name}".rstrip()
|
|
233
|
+
ans = input(f" Is this yours? {what} [y/N/a=all remaining] ").strip().lower()
|
|
234
|
+
if ans == "a":
|
|
235
|
+
eng.db.exec("UPDATE devices SET trusted=1 WHERE trusted=0")
|
|
236
|
+
break
|
|
237
|
+
if ans == "y":
|
|
238
|
+
eng.db.exec("UPDATE devices SET trusted=1 WHERE mac=?", (d["mac"],))
|
|
239
|
+
left = eng.db.query("SELECT COUNT(*) n FROM devices WHERE trusted=0")[0]["n"]
|
|
240
|
+
print(f"\nDone. {left} device(s) left unreviewed - they will show amber until you identify them "
|
|
241
|
+
"(your router's client list can help).\nStart with: n0rma run")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_rebaseline(a):
|
|
245
|
+
"""Accept everything currently listening / plugged in as normal (after you have checked it)."""
|
|
246
|
+
from . import det_host
|
|
247
|
+
db = DB()
|
|
248
|
+
p, u = det_host.listening(), det_host.usb_devices()
|
|
249
|
+
db.kv_set("host_ports", sorted(p))
|
|
250
|
+
db.kv_set("host_usb", sorted(u))
|
|
251
|
+
print(f"Accepted {len(p)} listening ports and {len(u)} USB devices as normal.")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_forget_network(a):
|
|
255
|
+
"""Moved house / changed router: forget the saved home network and device list, then re-learn."""
|
|
256
|
+
db = DB()
|
|
257
|
+
db.exec("DELETE FROM devices")
|
|
258
|
+
db.kv_set("lan_baseline", False)
|
|
259
|
+
db.exec("DELETE FROM kv WHERE k='lan_subnet'")
|
|
260
|
+
print("Forgot the home network. The next `n0rma setup` / `run` learns the current one.")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_scan(a):
|
|
264
|
+
"""One-shot scan of everything, print answers, exit."""
|
|
265
|
+
from . import det_ble, det_host, det_lan, det_wifi
|
|
266
|
+
eng = Engine(quiet=True)
|
|
267
|
+
ble = det_ble.BLEDetector(eng)
|
|
268
|
+
ble.start()
|
|
269
|
+
print("Scanning ~25s (Bluetooth + Wi-Fi + LAN + this computer)...", flush=True)
|
|
270
|
+
wd = det_wifi.WifiDetector(eng)
|
|
271
|
+
try:
|
|
272
|
+
wd.evaluate(wd.scan())
|
|
273
|
+
except Exception as e:
|
|
274
|
+
eng.status("drone", "watch", f"Wi-Fi: {e}", "wifi")
|
|
275
|
+
try:
|
|
276
|
+
det_lan.LanDetector(eng).scan()
|
|
277
|
+
except Exception as e:
|
|
278
|
+
eng.status("network", "watch", f"LAN: {e}", "lan")
|
|
279
|
+
try:
|
|
280
|
+
det_host.HostDetector(eng).check()
|
|
281
|
+
except Exception as e:
|
|
282
|
+
eng.status("host", "watch", str(e))
|
|
283
|
+
from . import det_sdr
|
|
284
|
+
det_sdr.SdrDetector(eng).start()
|
|
285
|
+
time.sleep(20)
|
|
286
|
+
ble._evaluate()
|
|
287
|
+
eng.stop.set()
|
|
288
|
+
for d, t in DOMAINS.items():
|
|
289
|
+
s = eng.state[d]
|
|
290
|
+
print(f"{ICON[s['level']]} {t}\n {s['msg']}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_selftest(a):
|
|
294
|
+
import os
|
|
295
|
+
import unittest
|
|
296
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
297
|
+
if not os.path.isdir(os.path.join(root, "tests")):
|
|
298
|
+
sys.exit("selftest needs a source checkout (tests/ not found).")
|
|
299
|
+
os.chdir(root)
|
|
300
|
+
sys.exit(unittest.main(module=None, argv=["x", "discover", "-s", "tests", "-t", "."]))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def legacy():
|
|
304
|
+
"""Old `homewatch` command: still works, but says it was renamed."""
|
|
305
|
+
print("note: homewatch is now called n0rma - use `n0rma` from now on.", file=sys.stderr)
|
|
306
|
+
main()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def main():
|
|
310
|
+
p = argparse.ArgumentParser(prog="n0rma", description="Home counter-surveillance detector")
|
|
311
|
+
sp = p.add_subparsers(dest="cmd", required=True)
|
|
312
|
+
r = sp.add_parser("run", help="run all detectors + dashboard")
|
|
313
|
+
r.add_argument("--lan", action="store_true", help="serve dashboard to phones on your LAN")
|
|
314
|
+
r.add_argument("--port", type=int, default=8777)
|
|
315
|
+
r.add_argument("--no-wifi", action="store_true")
|
|
316
|
+
r.set_defaults(f=cmd_run)
|
|
317
|
+
b = sp.add_parser("beep", help="log that the sensor just beeped")
|
|
318
|
+
b.add_argument("--ago", type=float, default=0, help="minutes ago")
|
|
319
|
+
b.add_argument("note", nargs="*")
|
|
320
|
+
b.set_defaults(f=cmd_beep)
|
|
321
|
+
rp = sp.add_parser("report", help="line up beeps against detections")
|
|
322
|
+
rp.add_argument("--hours", type=float, default=72)
|
|
323
|
+
rp.add_argument("--window", type=float, default=5, help="minutes either side of a beep")
|
|
324
|
+
rp.set_defaults(f=cmd_report)
|
|
325
|
+
sp.add_parser("devices", help="LAN inventory").set_defaults(f=cmd_devices)
|
|
326
|
+
t = sp.add_parser("trust", help="mark LAN device(s) as yours")
|
|
327
|
+
t.add_argument("mac")
|
|
328
|
+
t.set_defaults(f=cmd_trust)
|
|
329
|
+
nm = sp.add_parser("name", help="give a LAN device a name you will recognise")
|
|
330
|
+
nm.add_argument("mac")
|
|
331
|
+
nm.add_argument("label", nargs="*", help="empty clears the name")
|
|
332
|
+
nm.set_defaults(f=cmd_name)
|
|
333
|
+
i = sp.add_parser("ignore", help="mute a BLE address (your own tracker)")
|
|
334
|
+
i.add_argument("addr")
|
|
335
|
+
i.set_defaults(f=cmd_ignore)
|
|
336
|
+
f = sp.add_parser("find", help="hot/cold locator for a BLE device")
|
|
337
|
+
f.add_argument("addr")
|
|
338
|
+
f.set_defaults(f=cmd_find)
|
|
339
|
+
h = sp.add_parser("home", help="show/set home lat lon for the drone map")
|
|
340
|
+
h.add_argument("lat", type=float, nargs="?")
|
|
341
|
+
h.add_argument("lon", type=float, nargs="?")
|
|
342
|
+
h.set_defaults(f=cmd_home)
|
|
343
|
+
nt = sp.add_parser("ntfy", help="phone alerts via ntfy.sh")
|
|
344
|
+
nt.add_argument("action", choices=["setup", "test", "on", "off"])
|
|
345
|
+
nt.set_defaults(f=cmd_ntfy)
|
|
346
|
+
sp.add_parser("rebaseline", help="accept current listening ports + USB devices as normal").set_defaults(f=cmd_rebaseline)
|
|
347
|
+
sp.add_parser("forget-network", help="forget the saved home network and re-learn").set_defaults(f=cmd_forget_network)
|
|
348
|
+
sp.add_parser("setup", help="first-run walkthrough").set_defaults(f=cmd_setup)
|
|
349
|
+
sp.add_parser("scan", help="one-shot scan, print answers").set_defaults(f=cmd_scan)
|
|
350
|
+
sp.add_parser("selftest").set_defaults(f=cmd_selftest)
|
|
351
|
+
a = p.parse_args()
|
|
352
|
+
a.f(a)
|