Building a Cloudless UniFi Security System That Doesn't Suck

python

If you’re like me (rich), it’s likely that you want to monitor and protect your ice and gold chainz with technical measures. Since cloud based solutions are not an option for me, I’ve built a system that’s self-hosted. It’s based on my existing UniFi network setup, a UniFi camera and this software:

All of this can be self-hosted without requiring any cloud connections. In fact, you can add some firewall rules to prevent any outbound connections and it will still work as expected.

What to Build

Now, what this system should be doing is:

Enabling and disabling all of this based on the WiFi connection status of my smartphone. This way, the alarm system gets armed and disarmed automatically. For this to work as efficient as possible, the UniFi API of both the controller and the NVR can be of use. There’s only one slight problem: Some required API functions are undocumented. At least there’s some unofficial documentation available so not all is lost. Also, these repositories offer great Python libraries that can be used as well:

Websocket Event API

Websockets allow receiving event driven data without requiring constant polling for new info, so that’s pretty cool. Both the Controller and the NVR offer such an API. It’s undocumented as well but I’ll show my use cases in this section.

OK, how to inspect and check out websockets? Just use the Chrome developer tools and switch to the WS tab under Network after logging in into the Controller web application:

Da Socket

The Controller sends out messages that contain information on clients entering and leaving the WiFi network. The NVR also sends out a websocket message as soon as a new recording gets created. These are the relevant API endpoints for the websockets:

Let’s see how to get this event data using Python code.

Using the Websocket API

Luckily, the unifi-events repository I’ve mentioned above already contains some code that’s useful when interacting with websockets. I’ve modified it slightly for this post:

# Get a session cookie
jar = aiohttp.CookieJar(unsafe=True)
json_request = {    'username': CONTROLLER_USER,
                    'password': CONTROLLER_PASSWORD,
                    'strict': True
               }

async with aiohttp.ClientSession(cookie_jar=jar) as session:
    async with session.ws_connect(self.ws_url,
                                    autoping=True,
                                    heartbeat=5,
                                    ssl=self.ssl_verify,
                                    timeout=self.timeout) as ws:
        async for msg in ws:
            if msg.type == aiohttp.WSMsgType.TEXT:
                self.update_data(msg.json(loads=json.loads))
            elif msg.type == aiohttp.WSMsgType.BINARY:
                deflated = json.loads(zlib.decompress(msg.data))
                self.update_data(deflated)
            elif msg.type == aiohttp.WSMsgType.CLOSE:
                log.warn('WS closed: %s' % msg.extra)
                break
            elif msg.type == aiohttp.WSMsgType.ERROR:
                log.error('WS closed with Error')
                break

A few things are important here:

I’m creating a new thread for each websocket that adds data to a queue. The main thread then removes data from those queues and processes it.

Controller API Limitations ❤

The Controller is not aware of leaving WiFi clients in case they don’t send a disassociation frame. This happens if the client just walks away and loses the connection. There’s no way to get notified for this by the API because there’s simply no event being generated. This results in the alarm not being armed in this case.

Ping to the rescue. I’m pinging my phone regularly to determine the connection status. Since this runs in an own thread and may trigger camera settings to change, a mutex is now required to prevent race conditions with the main thread.

Disarming the alarm system works like a charm though, since this always generates an new event.

Camera API

OK cool, now the system is able to determine when to arm and it gets notified in case a new recording is available. It’s now required that the camera settings get adjusted accordingly. As already mentioned, I’ve used unifi-video-api for this purpose. This works via the normal HTTP API and has nothing to do with websockets.

Changing Settings

Changing camera settings isn’t fully implemented by the library for all settings though. That’s why I’m using my own wrapper functions. The current settings are being retrieved, changed sent back to the NVR:

def getCameraSettings():
    cfg = NVR.get("camera/%s" % (NVR_CAMERA_ID))["data"][0]
    assert cfg["deviceSettings"]["name"] == NVR_CAMERA_NAME
    return cfg

def setCameraSettings(led, sound):
    recMode = "disable" if SOMEONE_HOME else "motion"
    G3 = NVR.get_camera(NVR_CAMERA_NAME)
    G3.set_recording_settings(recording_mode=recMode)

    cfg = getCameraSettings()
    cfg["systemSoundsEnabled"] = sound
    cfg["enableSoundAlert"] = sound
    cfg["enableStatusLed"] = led
    cfg["enableSpeaker"] = sound
    cfg["enableRecordingIndicator"] = sound
    NVR.put("camera/%s" % (NVR_CAMERA_ID), data=cfg)

Downloading Recordings

It’s alert time, how to get the newest recording? I’ve found that you can generate and use an API key for the NVR that allows downloading the recordings. Here’s how to use it:

def isVideoInProgress(id):
    info = requests.get("%s/api/2.0/recording/%s/?apiKey=%s" %
                 (NVR_BASE_URL, id, NVR_API_KEY),
                 stream=True).json()

    inProgress = info["data"][0]["inProgress"]
    return inProgress

def getAndSendVideo(id):
    while isVideoInProgress(id):
        time.sleep(1)

    fpath = "/tmp/%s.mp4" % (id)
    with requests.get(
        "%s/api/2.0/recording/%s/download?apiKey=%s" % (NVR_BASE_URL, id, NVR_API_KEY), stream=True) as video:

        with open(fpath, "wb") as localfile:
            shutil.copyfileobj(video.raw, localfile)
    if "MPEG v4 " not in magic.from_file(fpath):
        raise ValueError("[Downloader] Error, Invalid recording")

    messaging.sendAttachment(CHAT_ID, fpath)

In case of errors it’s not guaranteed whether the server’s answer is really a video file, so I’m checking the magic bytes of the downloaded file to make sure everything is cool.

Alerting and Messaging

The NVR already supports email notifications but it’s 2021 and I want to use messenger services instead. I’ve decided to use two communication channels:

Using two messengers may seem strange, but on the other hand it’s not uncommon that a messenger is down for a whole day.

Signal Account Creation

Signal requires you to have access to a telephone number to create an account. This can be a mobile number or a landline number, but the important thing is that you keep the number. Otherwise shady people are able to re-register the number and take over your account.

If you’re living in Germany, you can get a free and dedicated number for a Signal account at satellite.me. In other countries, Google Voice may be an option to acquire a mobile number to complete the registration.

Signal-CLI Key Issues

I know this sucks but there’s a workaround: Signal-CLI fails to send messages at some point if no receive operation is being executed. I guess that the client runs out of rolling keys and that’s why it just fails quietly when sending another message. Nice. I guess having two messengers in parallel is a really good idea.

The workaround is to execute receive using a Cron job or before sending a new message.

Sonos Integration

I’ve got some Sonos devices, so why not play an alarm sound at full volume in case an alarm gets triggered? The SoCo library can be used for this exact purpose. Additionally, I’ve found the sonosplay repository that includes code to play an MP3 file on a Sonos device, so that’s what I’m using.

This code scans the network for all available Sonos zones and starts the MP3 playback:

def playEverywhere():
    try:
        # make sure to send multicast using the correct interface
        for zone in soco.discover(interface_addr=IP):
            zone.volume = 50
            playFile(zone)
    except Exception as e:
        messaging.sendImportant("Sonos play error: %s" % (str(e)))

def playFile(zone):
    stream_url = 'http://%s:%s/%s' % (IP, str(PORT), STREAM_PATH)
    log.info('Streaming on %s' % stream_url)
    try:
        zone.clear_queue()
        zone.play_uri(stream_url)
    except soco.exceptions.SoCoSlaveException as e:
        # Expected error, only Sonos group coordinators can start playback
        messaging.sendImportant("Sonos play file error: %s" % (str(e)))

The discovery process can be done for each playback since it’s quite fast and doesn’t introduce too much additional lag.

Monitoring

OK so it would be great if the system monitored the system’s health status. For example, a camera may disconnect or there may be high system load that interferes with the NVR operations. Luckily, the NVR websocket sends out this kind of information:

# a JSON object containing an NVR status update
data = getNVRUpdate()
action = data.get("action")
if action == "UPDATE":

    dataType = data.get("dataType", None)
    if dataType is None:
        return

    if dataType == "health":
        # disconnected cameras
        disconnectedCamera = data["data"]["camerasDisconnected"]
        if disconnectedCamera is not None:
            warningText = "Camera disconnected: %s" % str(
                disconnectedCamera)
            log.warn(warningText)
            messaging.sendImportant(warningText)

        # general status
        status = data["data"]["status"]
        phrase = data["data"]["statusPhrase"]
        if status != "GREEN":
            warningText = "Status: %s: %s" % (status, phrase)
            log.warn(warningText)
            messaging.sendImportant(warningText)

All status updates with status != "GREEN" can be monitored to detect system or camera errors.

Where’s the Code?

I might publish it at some point, maybe. I don’t know.


Now please don’t raid my place, I have JavaScript-enabled turrets around.

Does This Syncthing Work?

python backup

2 Common Python Security Issues

pentesting python