docs/site-agent.md
Rama main · réplica de lectura del repositorio original.
# 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.

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

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=<setup code from the dashboard> \
-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=<setup code from the dashboard>
```
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 <TV-IP> >/dev/null; ip neigh show <TV-IP>
```
**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 `<agent>-<date>.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@<ip>` 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.