# Site Agent A service that runs inside a venue's network and looks after the LG displays there: it checks whether they answer, starts the player app when it is not running, wakes a set before a schedule begins, and keeps the Developer Mode session alive so the TV does not delete the app. None of that can be done from the server. A consumer TV has no autostart, its Developer Mode session expires, and the signage server only ever sees the player's heartbeat — which cannot tell _the TV is off_ from _the TV is on and the app is not running_. Those are different problems with different fixes. The agent is never in the media path. If it is down, playback carries on. ![A site agent and the displays it looks after](img/site-agent-detail.png) What that page is showing, top to bottom: the agent itself, connected, with the version it reported. Then every display assigned to it, and the combined state the agent makes possible — two playing, one whose **TV is on with no app running** (the agent will start it), and one that **does not answer at all**, still half-way through setup because its key server is switched off. The agent's card also shows how its machine reaches the venue network, as of its last heartbeat: **LAN** or **Wi-Fi** with the network name (SSID), and the address the interface holds. The agent reads this from the interface carrying the default route — `/proc/net/route`, `/sys/class/net` and `iw` for the SSID. Host networking makes those describe the host, and none of them needs a capability. A driver without nl80211 support shows Wi-Fi without a name. Whether the address came from DHCP or is static cannot be told from inside the container and is not shown. Under the Docker development setup, where the agent is not on host networking, the card shows the container's own interface. When the agent runs an older release than the server, the dashboard flags it with **Update available**. Server and agent ship from the same release, so the server's own version is the reference — no registry or GitHub call is involved, and a development build on either side never raises the hint. Updating is a redeploy on the venue host: re-run `ansible/site-agent.yml`, or pull the new image and restart the service. `isOnline` on a screen still means only "the player checked in". Everything that tells you _why_ it did not is what the agent adds. --- ## One agent per venue ![The site agents of an organisation](img/site-agents.png) An organisation can have as many as it has networks. Each display belongs to at most one of them, and a display with no agent behaves exactly as it did before this existed. --- ## Install The agent is told once where it belongs through `MNS_SERVER_URL`, then connected from a browser with a short-lived **setup code** from the dashboard: ```bash podman run -d --name mynextscreen-agent \ --network host \ -v mynextscreen-agent-state:/var/lib/mynextscreen-agent:Z \ -e MNS_SERVER_URL=https://signage.example.com \ ghcr.io/flo-schilli/mynextscreen/site-agent:latest podman logs mynextscreen-agent ``` `MNS_SERVER_URL` is **required** — the agent refuses to start without it — and it must be an `https://` address. Pinning the address is what keeps anyone on the venue LAN from redirecting the agent at a server of their own, which is why there is no browser-chosen address and no boot PIN any more. Over plain `http://` that pin buys nothing: whoever answers the name receives the agent's refresh token, so the agent refuses it unless `MNS_ALLOW_INSECURE_SERVER_URL=true` is set, which is meant for a developer pointing an agent at `http://localhost:3000`. The log prints where to find the setup page and the pinned server, and nothing secret: ``` [SiteAgent] myNextScreen site agent 1.0.0 [SiteAgent] Server address pinned by MNS_SERVER_URL: https://signage.example.com [SiteAgent] Setup interface: http://192.168.1.20:8787 ``` **Host networking is required.** Wake-on-LAN is a broadcast packet, which a bridged network does not carry to the LAN. ### Connect it 1. In the dashboard, open **Site Agents → Add an agent**. The server shows a **setup code** once. It is org-scoped, single-use and valid for about fifteen minutes (see `SITE_AGENT_ENROLMENT_TTL_MS`). 2. Open the setup page from the log and paste the setup code. The server address is already fixed and shown read-only. That is all that is stored: from then on the agent holds a rotating session and re-reads its configuration from the server. Only an **org admin** of the target organisation can mint a setup code, so no dashboard credentials ever travel to the venue LAN. ### Reconnecting or resetting Disconnecting a running agent from its own setup page needs a **fresh setup code** too — the server verifies it (against the agent's own organisation) before it ends the session. That keeps a passer-by on the venue LAN from stranding the venue by hitting the agent's setup port. Mint a new code in the dashboard with **Site Agents → (your venue) → New setup code**, then use it on the agent's page. ### Unattended rollout Environment variables do the same thing without the browser: ```bash podman run -d --name mynextscreen-agent \ --network host \ -v mynextscreen-agent-state:/var/lib/mynextscreen-agent:Z \ -e MNS_SERVER_URL=https://signage.example.com \ -e MNS_ENROLMENT_TOKEN= \ -e MNS_SETUP_PORT=0 \ ghcr.io/flo-schilli/mynextscreen/site-agent:latest ``` `MNS_SETUP_PORT=0` leaves no inbound port open at all. `MNS_ENROLMENT_TOKEN` carries the same setup code the setup page would ask for: the agent redeems it once at boot, while it has no stored session, and logs the agent and organisation it joined. A code that is expired or already spent is logged as an error and nothing else happens — the agent keeps running without a session. That is deliberate: a deployment variable going stale is not a reason to take a correctly configured venue offline, and with the setup interface enabled an operator can still paste a fresh code. Issue a new one in the dashboard and restart. ### Unattended rollout with Ansible `ansible/site-agent.yml` does the above as a rootless Podman Quadlet, separate from `deploy.yml` because the agent runs on venue hardware rather than the server. Add the host to the `[site_agents]` group (see `hosts.ini.example`), then, for the first run only: ```bash ansible-playbook -i hosts.ini site-agent.yml -K \ -e site_agent_enrolment_token= ``` The code is written to `.env.mynextscreen-agent` on the host with mode `0600` and is spent on first boot. Re-running the playbook **without** the variable leaves the line out of the file again, so no stale code sits on the machine; the agent keeps the session it already has either way. Set `site_agent_setup_port=0` in the inventory for a venue that should have no inbound port at all. The playbook then also drops the image's health check, which probes that interface and would otherwise mark a healthy agent unhealthy forever. ### What wins over what `MNS_ENROLMENT_TOKEN` is only consulted when no session is stored, so a container restarted with a spent code keeps the session it already has rather than trying to redeem it again. `MNS_SERVER_URL` is mandatory and **wins over the stored address**; the setup page shows that field read-only. If it were ever absent the address would live only in `connection.json`, and anyone able to edit that file could point the agent at a server of their choosing — at which point the agent would present its real refresh token to that address on its next request, and whatever configuration came back would decide which hosts on the venue network it connects to. Requiring the variable closes that off at the source: a _restored backup from the wrong venue_, a configuration-management run, or a volume with loose permissions on the host cannot quietly move the agent. A stored address that disagrees with the pinned one is logged at startup and ignored. > **Upgrading an existing deployment:** set `MNS_SERVER_URL` on every agent > before updating to this version. An agent updated without it will not start, > by design. The old `MNS_SETUP_PIN` variable and the boot-time PIN are gone; > remove `MNS_SETUP_PIN` from any compose files or quadlets. | Variable | Default | What it does | | ----------------------- | ----------------------------- | -------------------------------------------------------- | | `MNS_SERVER_URL` | — | **Required**; `https://` where the agent belongs, read-only | | `MNS_ALLOW_INSECURE_SERVER_URL` | `false` | Permits an `http://` pin; local development only | | `MNS_ENROLMENT_TOKEN` | — | Optional setup code; only used when no session is stored | | `MNS_SETUP_PORT` | `8787` | `0` disables the setup interface entirely | | `MNS_STATE_DIR` | `/var/lib/mynextscreen-agent` | Session, keys and cached configuration | | `MNS_LOG_LEVEL` | `log` | | --- ## Connect a display Use the wizard: **Site Agents → (your venue) → Continue setup** on the display. It walks the eight steps below and checks each one against the actual TV, which is the point — every step is easy, and working out which one was missed from a single "could not connect" is not. | # | Where | What | | --- | ------------- | --------------------------------------------------------- | | 1 | Dashboard | Assign the display to this agent | | 2 | Dashboard | Enter the TV's address on the venue network | | 3 | **On the TV** | Install the Developer Mode app and switch Dev Mode on | | 4 | **On the TV** | Switch **Key Server** on in that app | | 5 | Dashboard | Enter the six-character passphrase the app shows | | 6 | Agent | Checks it can reach the TV over SSH | | 7 | **On the TV** | Confirm the pairing prompt with the remote — once per set | | 8 | Agent | Extends Developer Mode and starts the app | The passphrase derives from the set's own device id and does not change when Developer Mode is switched on again, so it only has to be entered once. ### The TV's own settings | Setting | Where | Why | | -------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------- | | **Quick Start+** | Settings → General → Energy Saving | Without it the network stack is dead in standby and the TV cannot be reached or woken | | **Mobile TV On** → _Turn on via Wi-Fi_ | Settings → General → Devices → External Devices | Wake-on-LAN. On webOS 4.x/5.x it is directly under General | Menu paths move with every webOS generation; searching the settings for `Mobile` is faster than clicking through. **Auto power off** and the **sleep timer** (Settings → General) switch the set off regardless of what any app asks for, and have to be disabled on the TV. --- ## Waking a display Both wake options need a MAC address, and the dashboard will not let you switch them on without one. Note that a TV's wired and wireless interfaces have _different_ MACs — the one for the interface actually in use is the one that works. Read it from Settings → General → About This TV, from the router, or while the set is on: ```bash ping -c1 >/dev/null; ip neigh show ``` **Wake before a schedule starts** is the normal choice: the agent wakes the set a configurable number of minutes before playback is due. It schedules that itself from the configuration it already holds, so a venue still comes up on time when the uplink is down. **Wake whenever the TV does not answer** is off by default. "The TV is off at 03:00" is not a fault to react to, so this one is for venues that keep their displays on around the clock. > **Trap:** after a set has been fully disconnected from power, Wake-on-LAN > stays dead until it is switched on once with the remote. --- ## How often displays are checked The agent probes every display it looks after once per **check interval**, 1 minute by default. Set it per agent on the agent page (**Check interval**, 1–10 minutes); the agent picks up a change straight away. **Check now** runs a round immediately, for example right after switching a set back on. The probe is a plain TCP connect, so it runs every interval no matter how long a set has been off: a TV switched on after two days is noticed within one interval. The agent then leaves it alone for **three minutes** before it starts the app or extends Developer Mode: a set that was just switched on has not fetched the time yet, and until it has, every TLS connection the app makes fails. The agent page shows the set as *TV detected · app starts around HH:MM* in the meantime. The same wait applies after the agent itself restarts, since it cannot tell how long the set has been on. The onboarding wizard does not wait. What backs off after repeated failures are the actions — a launch, a Developer Mode extension — so a set that keeps refusing them is not hammered. That backoff doubles from one interval up to 30 minutes and is cleared as soon as the set comes back after being unreachable, or when someone presses **Check now**. --- ## When a display's address changes Where the router cannot be configured, every DHCP lease renewal may move a TV to a new address. The agent follows it by its **MAC address**, so store the MAC of the interface the set actually uses (wired and wireless have different ones). The IP is still entered once during setup; the network step checks the set there. When a display with a MAC stops answering, the agent searches for it. The first search follows straight away; while the set stays missing — typically off overnight — the wait doubles from two minutes up to 30. 1. It asks every webOS set to announce itself over SSDP and reads the kernel's neighbour table (`/proc/net/arp`) for the MAC. 2. Only if **Allow subnet sweep** is switched on for the agent (off by default): if SSDP did not find it, it touches every address of the subnet the set was last seen in (up to a /22) on port 3001 and reads the table again. Container, VM and VPN interfaces are never swept. An intrusion detection system on the venue network may see this as a small port scan, which is why it needs an explicit opt-in. With it off, the agent contacts only sets that answered SSDP themselves. 3. It adopts the new address only when the set answers there, uses it in the same round and reports it. The server stores it, writes `screen.remote_address_changed` to the audit log and the dashboard updates the row. A set in standby answers neither SSDP nor ARP, so it is found once it is on again — Wake-on-LAN still reaches it, because the magic packet is broadcast to the MAC. **Check now** skips the two-minute wait. The server takes a new address only for a display that has a MAC stored, and only a unicast LAN address. Until the server has confirmed it, the agent keeps reporting the new address and holds on to it across config pulls. --- ## Developer Mode A Developer-Mode session lasts roughly 1000 hours (about 41 days), and when it expires the TV **deletes** the app. The agent extends it, but only while the set is on — there is no way to do it otherwise. That is why the interval matters. Seven days is the default and suits almost everything. The maximum is 40, and anything above about 30 only suits a display that effectively runs continuously: a set that spends a long weekend switched off can lose the session between two attempts. If an extension could not run because the set was off, it simply stays due and happens the next time the TV is on — **before** the app is started, since launching an app the TV has already deleted would fail for no visible reason. The agent deliberately does not report "time remaining". The TV's own countdown updates with a long delay, the session token is unreadable for the jailed user the agent connects as, and LG's own web endpoint reports a different number than the device. Any figure shown would be a guess. --- ## When something is wrong The display's row on the agent page carries the last status for each stage. | Key status | What to do | | ------------------ | -------------------------------------------------------------- | | `key_server_off` | Open the Developer Mode app on the TV and switch Key Server on | | `wrong_passphrase` | Re-enter the six characters the app shows | | `unreachable` | Check the address; make sure the set is on | | `no_passphrase` | Enter the passphrase in the remote control settings | | SSH status | What to do | | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `auth_failed` | Developer Mode was probably re-enabled, which issues a new key. Use **Refetch key** | | `host_key_mismatch` | The set's identity changed. A factory reset is the benign cause — anything else is worth looking at before clearing it | | `unreachable` | The set is off, or port 9922 is blocked | | Remote status | What to do | | ------------------ | ---------------------------------------------------------------- | | `awaiting_pairing` | Confirm the prompt on the TV with the remote | | `rejected` | The prompt was declined, or the stored key is no longer accepted | **The agent is offline.** Commands are refused rather than queued — a "start the app now" that fires six hours later is worse than none. Everything shown for its displays is then the last thing it reported, and the page says so. --- ## What the agent holds Its state directory contains a session token for one organisation, the SSAP client key for each TV, each TV's **encrypted** SSH key, and the Developer Mode passphrases. All of it is mode 0600 inside a 0700 directory, owned by the unprivileged user the container runs as. The TVs' private keys are never sent to the server and never stored there — the agent fetches them from each set's own key server and they stay in the venue. The decrypted key is never written to disk at all. In substance this is the same material an operator's laptop carries today, in one documented place rather than a shell history. Treat a backup of that volume accordingly. --- ## Replacing the box: backup and restore What is expensive to lose is what only the agent knows: the **SSAP pairing key** of each TV (without it someone has to stand in front of the set and confirm the pairing prompt again) and the cached **SSH key**. Deleting an agent in the dashboard also forgets each display's address, MAC and Developer Mode passphrase. A backup carries all of that for every display the agent looks after. It does not carry the agent's session: the new box is enrolled normally. **Export.** On the agent's page, *Backup → Export backup* (organisation admins, agent online). The agent seals the backup itself and generates its password; the browser downloads `-.mnsbackup` and shows the password **once**. Keep the two apart — the file is useless without the password, and the password is stored nowhere. **Import.** Enrol the new box — as the same agent (issue a new setup code for it) or as a new one — then *Backup → Import backup* on its page. A four-step dialog follows: 1. **File** — the dashboard reads the file's plaintext header and shows where it came from; a backup of another organisation is refused here. 2. **Password** — the agent opens the file. Nothing is written yet. 3. **Mapping** — what each display in the backup would do: - displays that still exist are restored by **filling gaps only** — anything entered since the backup was taken stays; - displays that belong to **another** agent are left alone and listed; - displays that **no longer exist** (deleted, or recreated with a new id) can be mapped onto an existing display, or skipped. Mapping says "this TV is now that display": the chosen display's settings *and* keys are overwritten with the backup's, which the dialog warns about. Only free displays and this agent's own can be chosen; another agent's are shown but disabled. 4. **Result** — what was restored, mapped and skipped. Displays an import creates, or maps, resume onboarding at the **final check** when the backup carries everything (address, passphrase, SSH key, pairing key); one click there talks to the set and starts the app. Missing data sends them back to the first step that needs it. Pairing keys from an older agent version are restored but reported: those TVs ask to pair once more. **One at a time, visible to every admin.** An agent runs one backup operation at a time. While one runs, the agent page shows it — kind, step, who started it — in every organisation admin's tab, and both buttons stay disabled until it ends. An import abandoned at the mapping step (dialog closed) is cancelled; one abandoned any other way frees the agent after ten minutes. **How it is protected.** - The file is encrypted on the agent with a key derived from a random 120-bit password (argon2id, AES-256-GCM); the plaintext header — organisation, source agent, date, display count — is authenticated with it. - The password never crosses the server in the clear. On export the agent seals it for a key pair that exists only in the browser tab that asked; on import the tab seals it for a key the agent made for that one import. The server relays sealed data only and keeps it in Redis for at most ten minutes, for the person who started the operation. - What the server holds while the operator maps entries is names and ids; the passphrases cross only when the import is applied, on their way into the database, where they are stored encrypted as always. - Progress events go to organisation admins only. - Both operations are recorded in the audit log, without any secret. A server that has been compromised could swap the keys during an export; the design protects the file at rest and the way through the server, not against the server itself — which holds the passphrases anyway. --- ## Verifying against a real set Two things no test can prove, because they depend on the device: 1. **The SSH algorithms.** A webOS set runs OpenSSH 6.1 and offers `ssh-rsa` with SHA-1 key exchange. The agent asks for exactly those. If a particular set refuses, `ssh -vvv -p 9922 prisoner@` shows what it does offer. 2. **The Developer Mode extension.** It reports success from the TV's own return value. The countdown shown on the set updates with a long delay, so check it again minutes later rather than immediately — an unchanged number straight afterwards is not a failure. --- ## Relation to the scripts `player-applications/lg-tvos/tools/` holds the two scripts this replaces: `launch-tv.mjs` and `extend-devmode.sh`. They remain useful for poking at a single set by hand, and their README is still the reference for how LG's remote protocols behave. The agent is what does the same job unattended, for every display in a venue, without an operator's laptop being involved.