benni.tech

Your Phone Is a Black Box in Your Homelab. Mine Isn't Anymore.

On a Thursday I noticed a note I had edited on my phone the day before was not on my laptop. Nextcloud said synced on both sides. The phone had no error. I had no idea how long the drift had been accumulating — the app gives you a spinner and a green checkmark and nothing else.

Every server in my homelab has a /status endpoint I can curl over Tailscale. The phone had nothing. It was the only device in my setup I could not observe without picking it up.

What follows requires an Android phone, Termux, Tailscale on all devices, and a Linux server you control. If you are running Nextcloud without a homelab behind it, this is not for you — but the problem it solves is.

The insight: you don't need to see inside the app

The Nextcloud Android app gives you no queryable interface. You cannot ask it what it is doing, what it last synced, or why it stopped. But the app is not the only source of truth — it leaves files behind, and Nextcloud serves those same files over WebDAV. An observer outside the app can compare the two and report the difference.

That is what the agent does. It runs in Termux, never touches your files, and checks each configured folder by listing what is on disk and what Nextcloud serves over WebDAV. The diff is the sync state. No root, no app hooks, no Nextcloud API beyond standard WebDAV.

The technique in 50 lines

If you do not have the full homelab stack, you can run this standalone. It needs only Python — no pip, no dependencies, works in Termux after pkg install python and on any desktop Python 3.6+.

#!/usr/bin/env python3
# Check whether a local folder matches its Nextcloud counterpart over WebDAV.
# Usage: python3 nc-check.py <url> <user> <app-password> <remote-path> <local-path>
# <remote-path> is relative to your Nextcloud files root, e.g. Notes or Documents/Work
import sys, urllib.request, urllib.parse, xml.etree.ElementTree as ET
from base64 import b64encode
from pathlib import Path

def webdav_list(url, user, password, remote_path):
    dav = "{}/remote.php/dav/files/{}/{}/".format(
        url.rstrip("/"), urllib.parse.quote(user, safe=""), remote_path.strip("/")
    )
    auth = "Basic " + b64encode("{}:{}".format(user, password).encode()).decode()
    req = urllib.request.Request(dav, method="PROPFIND",
                                  headers={"Depth": "1", "Authorization": auth})
    with urllib.request.urlopen(req) as r:
        tree = ET.fromstring(r.read())
    ns = {"d": "DAV:"}
    out = {}
    for resp in tree.findall("d:response", ns):
        if resp.find(".//d:collection", ns) is not None:
            continue  # skip the directory entry itself
        href = (resp.findtext("d:href", namespaces=ns) or "").rstrip("/")
        name = urllib.parse.unquote(href.split("/")[-1])
        el = resp.find(".//d:getcontentlength", ns)
        out[name] = int(el.text) if el is not None else 0
    return out

def local_list(local_path):
    return {f.name: f.stat().st_size for f in Path(local_path).iterdir() if f.is_file()}

if len(sys.argv) != 6:
    sys.exit("usage: nc-check.py <url> <user> <app-password> <remote-path> <local-path>")
_, url, user, pw, remote, local_path = sys.argv

remote_files = webdav_list(url, user, pw, remote)
local_files  = local_list(local_path)

behind   = sorted(remote_files.keys() - local_files.keys())
ahead    = sorted(local_files.keys() - remote_files.keys())
mismatch = sorted(k for k in local_files.keys() & remote_files.keys()
                  if local_files[k] != remote_files[k])

if not behind and not ahead and not mismatch:
    print("✓ in sync")
else:
    for label, files in [("behind", behind), ("ahead", ahead), ("size mismatch", mismatch)]:
        if files:
            print("{} — {} file(s):".format(label, len(files)))
            for f in files[:10]: print("  " + f)

Use a dedicated app password (Nextcloud Settings → Security → App passwords), not your main password. The script compares by file name and size — fast, but won't catch a file that was silently corrupted with the same size. For the phone, the local path is wherever the Nextcloud app stores its files; on Android this is typically /storage/emulated/0/Android/media/com.nextcloud.client/nextcloud/<user>/<folder>, though it varies.

This checks one folder at one depth level. The full agent adds battery and network context, recursive folder walks, photo upload tracking, automatic polling, and the REST endpoint so you can query it from elsewhere. But this is the core of it.

What the agent reports

Here is what curl -s -H "Authorization: Bearer $TOKEN" http://fairphone-6.tail326738.ts.net:8765/status returned when the Thursday drift finally had a name:

{
  "device": "mobile",
  "daemon_running": false,
  "last_sync": "2026-10-22T11:03:00Z",
  "problems": ["folder_behind:brain"],
  "battery": {"percentage": 67, "plugged": false, "status": "DISCHARGING"},
  "network": {"wifi_connected": true},
  "server": {"reachable": true, "webdav_auth": true, "latency_ms": 23},
  "folders": [{
    "name": "brain",
    "local_files": 847,
    "remote_files": 850,
    "behind": [
      "daily/2026-10-23.md",
      "daily/2026-10-24.md",
      "daily/2026-10-25.md"
    ],
    "ahead": [],
    "conflicts": []
  }]
}

Wi-Fi connected, battery fine, server reachable, WebDAV auth working — so nothing environmental. Three daily notes from the last three days were on the server but not on the phone. The Nextcloud app had simply stopped downloading them, silently, at some point on Wednesday. The fix was opening the app and pulling to refresh.

The fields worth noting: daemon_running: false here does not mean the agent is down — it means there is an active problem. (true means everything matches.) last_sync is the last time everything agreed, not a log line from the Nextcloud app. Differences must persist 15 minutes before they appear in problems — a note you just edited does not count.

Installing it

Install Termux, Termux:API, and Termux:Boot from F-Droid — not the Play Store; the Play Store builds are unmaintained and miss API features. Open Termux:Boot once so Android registers it as a boot handler.

The agent needs two packages:

pkg install -y python termux-api

That is the only pkg install. The agent itself is stdlib-only — no pip needed in Termux, no virtualenv, no dependency hell on a phone.

The install script writes the agent, the restart loop, and a Termux:Boot autostart entry:

MCP_URL=https://server2.tail.ts.net:8766 \
NC_PROVISION_CODE=<one-time-code> \
bash termux-agent-install.sh

With MCP_URL and NC_PROVISION_CODE set, the script downloads the agent from the MCP server and pulls all configuration (token, Nextcloud URL, app password, folder paths) from a /provision/mobile endpoint — no manual editing. Without them it writes a template you fill in by hand. Either way the env file lands at ~/.config/nc-sync-agent/agent.env with chmod 600.

After the script finishes the agent is running and starts automatically after every reboot (via Termux:Boot). The MCP server on server2 picks it up next to the laptop agent.

The thing that will silently break this

Android battery optimisation kills background processes. This is the most common reason a Termux agent works during setup and stops working three days later.

Go to Android Settings → Apps, and for each of these set Battery to Unrestricted (not "Optimised", not "Restricted"):

Also: Tailscale → Always-on VPN. Without this the phone drops off the Tailscale network when the screen is off and the agent becomes unreachable from server2.

If you skip this step the agent will appear to work and then quietly disappear. The setup docs warn about this, but I am putting it here because it is the one thing that looks like a code problem but is an Android settings problem.

What the agent cannot tell you

It cannot see inside the Nextcloud app. It does not know whether the app is running, whether it is actively syncing, or what it last logged. It only sees the result: files on disk versus files on WebDAV.

Files the app has not downloaded because they are not marked "available offline" are not on the phone by design. If you point the agent at a folder you have not set to offline-available, it will permanently report folder_behind. Configure only folders you actually have offline.

Photos are matched by filename and size. If the Nextcloud app renames uploads on the server side, the agent may report photos_stale when the photos are actually there under a different name.

The result

The phone now appears in my homelab's device list next to the laptop and server2. One make nextcloud-sync-mcp-devices call shows all three, from the same MCP server, with the same fields. When sync breaks silently — and it does break silently, regularly — I get a Telegram alert within 15 minutes instead of finding out when a file is missing.

The agent is a few hundred lines of Python. The hardest part was not the code — it was accepting that the phone could be a first-class node in a homelab rather than a peripheral you occasionally look at.