nerdymark's Software Engineering & Cybersecurity Blog

Welcome to my digital homestead - a curated collection of projects, writeups, and experiments in Python, cybersecurity, and creative coding.

Here you'll find CTF writeups covering Azure OAuth privilege escalation, AWS S3 multi-service exploitation, Kubernetes SSRF attack chains, Terraform state poisoning, Go malware reverse engineering, and supply-chain compromises on GitHub Actions runners. You'll also find side projects like the Pokemon Sleep Roster Analyzer, an RDP/VNC network scanner, a LinkedIn feed analyzer powered by Gemini, and word-puzzle solvers for Wordle and Hardle. Plus notes on building this Flask site, migrating to AWS, Bluesky cross-posting, and running a personal AI robot out of my garage.

I'm Mark LaCore - Software Engineer by day, Raspberry Pi tinkerer by night. 25 years of turning caffeine into code, 20+ years of playing guitar, and a growing collection of CTF trophies. Explore the posts below, browse the CTF writeups, or drop me a line.

Add Caw (nerdymark.com) notifications: discovery, burst, lost-contact New expor...

File: auto_rx/auto_rx.py
 from autorx.logger import TelemetryLogger
 from autorx.email_notification import EmailNotification
 from autorx.bluesky import BlueskyNotification
+from autorx.caw import CawNotification
 from autorx.aprs import APRSUploader
 from autorx.ozimux import OziUploader
 from autorx.sondehub import SondehubUploader
 def main():
         exporter_objects.append(_bluesky_notification)
         exporter_functions.append(_bluesky_notification.add)
+    # Caw (nerdymark.com) Notifications (nerdscan fork)
+    if config["caw_enabled"]:
+        _caw_notification = CawNotification(
+            caw_url=config["caw_url"],
+            discovery_notifications=config["caw_discovery_notifications"],
+            burst_notifications=config["caw_burst_notifications"],
+            lost_contact_notifications=config["caw_lost_contact_notifications"],
+            burst_altitude_threshold=config["caw_burst_altitude_threshold"],
+            lost_contact_minutes=config["caw_lost_contact_minutes"],
+            station_position=(
+                config["station_lat"],
+                config["station_lon"],
+                config["station_alt"],
+            ),
+            station_callsign=config["habitat_uploader_callsign"],
+        )
+        exporter_objects.append(_caw_notification)
+        exporter_functions.append(_caw_notification.add)
+
     # Sondehub v2 Database
     if config["sondehub_enabled"]:
         if config["habitat_upload_listener_position"] is False:
File: auto_rx/autorx/caw.py
+#!/usr/bin/env python
+#
+#   radiosonde_auto_rx - Caw Notifications (nerdymark.com)
+#
+#   Posts radiosonde discovery / burst / lost-contact events to nerdymark.com's
+#   "caw" feed — an ephemeral, location-based social feed (caws live one hour).
+#   All caws are posted at the STATION location, so they appear in the operator's
+#   local feed; the sonde's distance/bearing goes in the text. Anonymous POST,
+#   no auth, like the site's other public write endpoints.
+#
+#   POST /api/caw  {latitude, longitude, timestamp, caw}
+#
+#   Released under GNU GPL v3 or later
+#
+import datetime
+import json
+import logging
+import os
+import time
+
+import requests
+
+from queue import Queue
+from threading import Thread
+
+import autorx
+from .utils import position_info
+from .geometry import GenericTrack
+
+
+_COMPASS = [
+    "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+    "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
+]
+
+
+def _compass(bearing):
+    return _COMPASS[int((bearing % 360.0) / 22.5 + 0.5) % 16]
+
+
+class CawNotification(object):
+    """Radiosonde Caw Notification Class.
+
+    Accepts telemetry dictionaries from a decoder and posts a caw on discovery
+    (first frame), burst (descent onset), and lost-contact (no telemetry for
+    lost_contact_minutes). Incoming telemetry is processed via a queue, so this
+    object should be thread safe. The lost-contact check runs off the queue loop's
+    own tick, so it fires even when the sonde has gone silent.
+    """
+
+    REQUIRED_FIELDS = ["id", "lat", "lon", "alt", "type", "freq"]
+
+    # Discard state for sondes not heard in this long (seconds). Must exceed the
+    # lost-contact window so the lost-contact caw fires before cleanup.
+    SONDE_MAX_AGE = 3600 * 3
+
+    # Don't re-announce an event for a sonde id within this window (seconds).
+    # Persisted, so the supervisor auto-restarting auto_rx doesn't re-caw.
+    NOTIFIED_TTL = 3600 * 24
+
+    # Politeness backstop between caws (seconds); the endpoint is also rate-limited.
+    MIN_POST_INTERVAL = 8
+
+    BURST_DESCENT_TRIP = 10
+    CAW_MAX_CHARS = 280
+
+    def __init__(
+        self,
+        caw_url="https://nerdymark.com/api/caw",
+        discovery_notifications=True,
+        burst_notifications=True,
+        lost_contact_notifications=True,
+        burst_altitude_threshold=5000,
+        lost_contact_minutes=15,
+        station_position=None,
+        station_callsign=None,
+    ):
+        self.caw_url = caw_url
+        self.discovery_notifications = discovery_notifications
+        self.burst_notifications = burst_notifications
+        self.lost_contact_notifications = lost_contact_notifications
+        self.burst_altitude_threshold = burst_altitude_threshold
+        self.lost_contact_s = lost_contact_minutes * 60.0
+        self.station_position = station_position
+        self.station_callsign = station_callsign
+
+        self.sondes = {}
+
+        self.notified_file = os.path.join(autorx.logging_path, "caw_notified.json")
+        self.notified = self._load_notified()
+
+        self.last_post_time = 0
+
+        self.input_queue = Queue()
+        self.input_processing_running = True
+        self.input_thread = Thread(target=self.process_queue)
+        self.input_thread.start()
+
+        if self.station_position is None or self.station_position[0] == 0.0:
+            self.log_error("No station position set — caws need a location; disabling.")
+            self.input_processing_running = False
+        else:
+            self.log_info("Started Caw Notifier Thread (posting to %s)" % self.caw_url)
+
+    # ------------------------------------------------------------------ queue
+
+    def add(self, telemetry):
+        for _field in self.REQUIRED_FIELDS:
+            if _field not in telemetry:
+                self.log_error("JSON object missing required field %s" % _field)
+                return
+        if self.input_processing_running:
+            self.input_queue.put(telemetry)
+
+    def process_queue(self):
+        while self.input_processing_running:
+            while self.input_queue.qsize() > 0:
+                try:
+                    _telem = self.input_queue.get_nowait()
+                    self.process_telemetry(_telem)
+                except Exception as e:
+                    self.log_error("Error processing telemetry dict - %s" % str(e))
+
+            time.sleep(2)
+            self.check_lost_contact()      # fires on silence, off our own tick
+            self.clean_telemetry_store()
+
+    def close(self):
+        self.input_processing_running = False
+        if self.input_thread is not None:
+            self.input_thread.join(60)
+        self.log_debug("Stopped Caw Notifier Thread")
+
+    # ------------------------------------------------------------ event logic
+
+    def process_telemetry(self, telemetry):
+        _id = telemetry["id"]
+
+        if _id not in self.sondes:
+            self.sondes[_id] = {
+                "last_time": time.time(),
+                "max_alt": telemetry["alt"],
+                "descending_trip": 0,
+                "ascent_trip": False,
+                "burst_notified": _id in self.notified and "burst" in self.notified[_id],
+                "lost_notified": _id in self.notified and "lost" in self.notified[_id],
+                "last_telem": telemetry,
+                "track": GenericTrack(max_elements=20),
+            }
+            self.sondes[_id]["track"].add_telemetry({
+                "time": telemetry["datetime_dt"], "lat": telemetry["lat"],
+                "lon": telemetry["lon"], "alt": telemetry["alt"],
+            })
+            if self.discovery_notifications:
+                if _id in self.notified and "discovery" in self.notified[_id]:
+                    self.log_debug("Sonde %s already announced, skipping discovery caw." % _id)
+                else:
+                    self.post_discovery(telemetry)
+                    self._mark_notified(_id, "discovery")
+            return
+
+        # Existing sonde — update flight state.
+        _state = self.sondes[_id]["track"].add_telemetry({
+            "time": telemetry["datetime_dt"], "lat": telemetry["lat"],
+            "lon": telemetry["lon"], "alt": telemetry["alt"],
+        })
+        self.sondes[_id]["last_time"] = time.time()
+        self.sondes[_id]["last_telem"] = telemetry
+        if telemetry["alt"] > self.sondes[_id]["max_alt"]:
+            self.sondes[_id]["max_alt"] = telemetry["alt"]
+
+        if self.sondes[_id]["burst_notified"] or not _state:
+            return
+
+        if telemetry["alt"] > self.burst_altitude_threshold:
+            self.sondes[_id]["ascent_trip"] = True
+        if self.sondes[_id]["ascent_trip"] and (_state["ascent_rate"] < -2.0):
+            self.sondes[_id]["descending_trip"] += 1
+
+        if self.sondes[_id]["descending_trip"] > self.BURST_DESCENT_TRIP:
+            self.sondes[_id]["burst_notified"] = True
+            self.log_info("Sonde %s burst detected." % _id)
+            if self.burst_notifications:
+                self.post_burst(telemetry, _state)
+                self._mark_notified(_id, "burst")
+
+    def check_lost_contact(self):
+        """Post a lost-contact caw for any sonde we haven't heard from in
+        lost_contact_minutes (once per sonde)."""
+        if not self.lost_contact_notifications:
+            return
+        _now = time.time()
+        for _id, _s in self.sondes.items():
+            if _s["lost_notified"]:
+                continue
+            if (_now - _s["last_time"]) >= self.lost_contact_s:
+                _s["lost_notified"] = True
+                self.log_info("Sonde %s lost — no contact for %.0f min." %
+                              (_id, self.lost_contact_s / 60.0))
+                self.post_lost_contact(_id, _s)
+                self._mark_notified(_id, "lost")
+
+    def clean_telemetry_store(self):
+        _now = time.time()
+        for _id in list(self.sondes.keys()):
+            if (_now - self.sondes[_id]["last_time"]) > self.SONDE_MAX_AGE:
+                self.sondes.pop(_id)
+
+    # ------------------------------------------------------------- caw text
+
+    def _type_str(self, telemetry):
+        return telemetry.get("subtype", telemetry["type"])
+
+    def _range_str(self, telemetry):
+        """'· 42 km NE' from the station to the sonde, or '' if no station pos."""
+        if self.station_position is None or self.station_position[0] == 0.0:
+            return ""
+        try:
+            _rel = position_info(
+                self.station_position,
+                (telemetry["lat"], telemetry["lon"], telemetry["alt"]))
+            return " · %.0f km %s" % (
+                _rel["straight_distance"] / 1000.0, _compass(_rel["bearing"]))
+        except Exception:
+            return ""
+
+    def post_discovery(self, telemetry):
+        _id = telemetry["id"]
+        if telemetry.get("encrypted", False):
+            _text = "🔒 Encrypted radiosonde detected: %s %s on %s (no position)" % (
+                self._type_str(telemetry), _id, telemetry["freq"])
+        else:
+            _text = "🎈 New radiosonde: %s %s on %s · %s m%s" % (
+                self._type_str(telemetry), _id, telemetry["freq"],
+                "{:,}".format(int(telemetry["alt"])), self._range_str(telemetry))
+        self.post_caw(_text)
+
+    def post_burst(self, telemetry, sonde_state):
+        _id = telemetry["id"]
+        _text = "💥 Balloon burst! %s %s peaked at %s m, now descending %.0f m/s%s" % (
+            self._type_str(telemetry), _id,
+            "{:,}".format(int(self.sondes[_id]["max_alt"])),
+            abs(sonde_state["ascent_rate"]), self._range_str(telemetry))
+        self.post_caw(_text)
+
+    def post_lost_contact(self, _id, sonde):
+        _t = sonde.get("last_telem") or {}
+        _mins = int(self.lost_contact_s / 60.0)
+        _alt = "{:,} m".format(int(_t["alt"])) if "alt" in _t else "unknown alt"
+        _text = "📡 Lost contact with %s %s — no telemetry for %d min, last heard at %s%s" % (
+            (_t.get("subtype") or _t.get("type") or "sonde"), _id, _mins, _alt,
+            self._range_str(_t) if "lat" in _t else "")
+        self.post_caw(_text)
+
+    # ------------------------------------------------------------------ post
+
+    def post_caw(self, text):
+        """POST a caw at the station location. Failures logged, never raised."""
+        if self.station_position is None or self.station_position[0] == 0.0:
+            self.log_error("No station position — cannot post caw.")
+            return
+        _since = time.time() - self.last_post_time
+        if _since < self.MIN_POST_INTERVAL:
+            time.sleep(self.MIN_POST_INTERVAL - _since)
+        try:
+            _resp = requests.post(self.caw_url, json={
+                "latitude": self.station_position[0],
+                "longitude": self.station_position[1],
+                "timestamp": datetime.datetime.now(datetime.timezone.utc)
+                .isoformat().replace("+00:00", "Z"),
+                "caw": text[:self.CAW_MAX_CHARS],
+            }, timeout=30)
+            _resp.raise_for_status()
+            self.last_post_time = time.time()
+            self.log_info("Posted caw: %s" % text[:60])
+        except Exception as e:
+            self.log_error("Error posting caw - %s" % str(e))
+
+    # ------------------------------------------------------------ persistence
+
+    def _load_notified(self):
+        try:
+            with open(self.notified_file, "r") as f:
+                _data = json.load(f)
+            _cutoff = time.time() - self.NOTIFIED_TTL
+            out = {}
+            for _id, _events in _data.items():
+                _ts = [v for v in _events.values() if isinstance(v, (int, float))]
+                if _ts and max(_ts) > _cutoff:
+                    out[_id] = _events
+            return out
+        except FileNotFoundError:
+            return {}
+        except Exception as e:
+            self.log_error("Could not read caw-notified file - %s" % str(e))
+            return {}
+
+    def _mark_notified(self, _id, event):
+        if _id not in self.notified:
+            self.notified[_id] = {}
+        self.notified[_id][event] = time.time()
+        try:
+            with open(self.notified_file, "w") as f:
+                json.dump(self.notified, f)
+        except Exception as e:
+            self.log_error("Could not write caw-notified file - %s" % str(e))
+
+    # ----------------------------------------------------------------- logging
+
+    def log_debug(self, line):
+        logging.debug("Caw - %s" % line)
+
+    def log_info(self, line):
+        logging.info("Caw - %s" % line)
+
+    def log_error(self, line):
+        logging.error("Caw - %s" % line)
+
+
+if __name__ == "__main__":
+    # Test: python -m autorx.caw   (posts a single test caw at the coords below)
+    logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s", level=logging.DEBUG)
+    _c = CawNotification(station_position=(37.324, -121.890, 30.0), station_callsign="KO6ODW")
+    _c.post_caw("🐦 caw notifier test — radiosonde bot online")
+    _c.close()
File: auto_rx/autorx/config.py
 def read_auto_rx_config(filename, no_sdr_test=False):
             except:
                 auto_rx_config["bluesky_burst_altitude_threshold"] = 5000.0
+        # nerdscan fork - Caw (nerdymark.com) notification settings
+        try:
+            auto_rx_config["caw_enabled"] = config.getboolean("caw", "enabled")
+        except:
+            logging.debug("Config - No [caw] section, Caw notifications disabled.")
+            auto_rx_config["caw_enabled"] = False
+
+        if auto_rx_config["caw_enabled"]:
+            try:
+                auto_rx_config["caw_url"] = config.get("caw", "url")
+            except:
+                auto_rx_config["caw_url"] = "https://nerdymark.com/api/caw"
+            try:
+                auto_rx_config["caw_discovery_notifications"] = config.getboolean(
+                    "caw", "discovery_notifications")
+                auto_rx_config["caw_burst_notifications"] = config.getboolean(
+                    "caw", "burst_notifications")
+                auto_rx_config["caw_lost_contact_notifications"] = config.getboolean(
+                    "caw", "lost_contact_notifications")
+            except:
+                auto_rx_config["caw_discovery_notifications"] = True
+                auto_rx_config["caw_burst_notifications"] = True
+                auto_rx_config["caw_lost_contact_notifications"] = True
+            try:
+                auto_rx_config["caw_lost_contact_minutes"] = config.getint(
+                    "caw", "lost_contact_minutes")
+            except:
+                auto_rx_config["caw_lost_contact_minutes"] = 15
+            try:
+                auto_rx_config["caw_burst_altitude_threshold"] = config.getfloat(
+                    "caw", "burst_altitude_threshold")
+            except:
+                auto_rx_config["caw_burst_altitude_threshold"] = 5000.0
+
         # If we are being called as part of a unit test, just return the config now.
         if no_sdr_test:
             return auto_rx_config
Read more...