# 9. Ring Remote View reference

*Canonical HTML version: https://ring-rs3000.pages.dev/docs/reference/09-remote-view-reference.html*

**Who this is for.** Plant IT, integrators adding a site, a security
reviewer checking the package's claims against its code, and a future
developer who needs an exact value without re-reading every script.

**Not a walkthrough.** For the guided install see
[installer/07 — Ring Remote View](../installer/07-remote-view.md) (v1, LAN)
and [installer/07a — off-site](../installer/07a-remote-view-off-site.md) (v2,
Cloudflare Tunnel). For what an operator sees, see
[operator/10 — Remote viewing](../operator/10-remote-viewing.md); for where
this sits in the plant network, see
[reference/02 §2.4](02-network-and-endpoints.md#remote-access).

Ring Remote View is a **separate, optional** package — a pinned, hardened
[MeshCentral](https://github.com/Ylianst/MeshCentral) server plus a portable
Node runtime, installed into `C:\Ring\RemoteView\`. It mirrors the one live
Ring HMI session; it never starts a second Ring, never opens a second PLC
connection, and no Ring file is touched by anything in it. Every table below
comes from the package's own scripts and config template, the two runbooks,
and a bench verification pass run **2026-09-04** — where that pass found
something a runbook got wrong, or left unproven, this chapter says so.

---

## 9.1 Pinned constants {#pinned-constants}

| Item | Value |
|---|---|
| Product display name | `Ring Remote View` (a wrapper name — MeshCentral itself is not rebranded) |
| MeshCentral version | **1.2.4** (Apache-2.0, `engines.node >= 20`) |
| Node runtime | **Node 24 LTS ("Krypton"), win-x64, portable** — bundled, never installed machine-wide. Node 20 left maintenance 2026-04-30; the build refuses a line already past end-of-life. Node 24's own EOL: 2028-04-30. |
| Install root | `C:\Ring\RemoteView\` |
| HTTPS port (web UI) | **8443** |
| MPS port | **4433** — Intel AMT presence-server port, bound to `127.0.0.1`, no inbound rule. See [§9.2](#listeners-and-firewall). |
| HTTP redirect port | disabled (`redirPort: 0` — no plaintext listener exists) |
| Server service — **display name** | `MeshCentral` |
| Server service — **real service name** | **`meshcentral.exe`**, a node-windows daemon at `<root>\meshcentral\WinService\daemon\meshcentral.exe`. The SCM service name is the exe name, not "MeshCentral." Bench-confirmed 2026-09-04. |
| Agent service — display name | `Mesh Agent` |
| Agent service — binary | `C:\Program Files\Mesh Agent\MeshAgent.exe` |
| Device group | `Plant HMI` |
| Device group features | **4** (Record Sessions) |
| Device group consent | **9** (1 desktop notify + 8 desktop prompt) |
| Control mask | **12586504** |
| View-only mask | **12586760** |

`test\Verify-RemoteViewStatic.ps1` asserts every value above across every
script and the config template — see [§9.7](#verification-evidence).

### v2 pinned constants (Cloudflare Tunnel, optional) {#v2-pinned-constants}

| Item | Value |
|---|---|
| cloudflared version (pinned) | **2026.7.3** |
| cloudflared asset | `cloudflared-windows-amd64.exe` |
| cloudflared SHA256 (pinned) | `8635DA433B6DF8194746E88ED9D2589566C20E38BFC2A80E431A348B7C765841` |
| cloudflared licence | Apache-2.0 |
| Windows service | `cloudflared`, Automatic start, `--no-autoupdate` pinned into the ImagePath |
| Binary location | `C:\Ring\RemoteView\cloudflared\cloudflared.exe` |
| Outbound ports | TCP **443** (required), UDP **7844** (QUIC, optional) |
| Inbound ports added | **none, on any NIC** — asserted by the installer |

---

## 9.2 Listeners and firewall {#listeners-and-firewall}

### What actually listens (bench-observed, 2026-09-04)

| Port | Observed |
|---|---|
| **8443** | Listening on `0.0.0.0:8443` and `[::]:8443`, TLS 1.3. Plain HTTP gets no response at all. |
| **4433 (MPS)** | **No socket at all**, not merely "bound to loopback." MeshCentral 1.2.4's `mpsserver.js` line 42: `if ((args.lanonly != true) && (args.mpsport !== 0))` — in LAN-only mode (always on here) the AMT MPS listener never starts. The runbooks' "bound to `127.0.0.1`" is conservative; the bench truth is "not listening." |
| Plant/PLC NIC | Nothing inbound, ever. |

### Inbound firewall rules

| Rule | Created by | Scope | Purpose |
|---|---|---|---|
| `Ring Remote View (HTTPS 8443)` | `New-RemoteViewFirewallRule.ps1` (installer calls it unless `-NoFirewall`) | TCP 8443 inbound, `RemoteAddress = LocalSubnet`, `Profile = Domain,Private` (never `Public`/`Any`), optionally one NIC via `-InterfaceAlias` | The only inbound surface this package adds on purpose |
| **`Mesh Agent WebRTC Traffic`** | **The Mesh Agent's own installer** (`meshagent64 -fullinstall`) — not created or documented by this package or either runbook. Bench finding, 2026-09-04. | Allow, UDP, any port, program `MeshAgent.exe`, profiles **Domain+Private+Public** | Unused (`webRTC: false` always), but present on every install with the local agent. Disable with `Disable-NetFirewallRule -DisplayName 'Mesh Agent WebRTC Traffic'`, or accept it explicitly. Removed automatically by `MeshAgent.exe -fulluninstall` — bench-confirmed gone after uninstall. |

No rule is ever created for MPS 4433.

> **Dual-homed:** `LocalSubnet` alone is not enough if the plant subnet is
> also "local" — bind the rule to the office NIC with `-InterfaceAlias`; the
> installer requires both `-LanCidr` and `-InterfaceAlias` together on a
> multi-homed box and asserts the rule's interface filter matches.
> **Profile:** the rule is `Domain,Private` only — if the NIC's
> `Get-NetConnectionProfile` category is **Public**, the rule does nothing
> and 8443 is unreachable with no error (the installer warns in its
> summary). The 2026-09-04 bench run hit exactly this and used
> `-NoFirewall` instead, since the lab's only NIC was Public.

**v2 is outbound-only.** `cloudflared` dials out on TCP 443 (and UDP 7844 for
QUIC). The v2 installer creates no inbound rule and asserts that none exists
referencing `cloudflared`.

---

## 9.3 Files and folders on the HMI PC {#files-and-folders}

```
C:\Ring\RemoteView\
    node\                         portable Node 24 LTS "Krypton" (not machine-wide)
    meshcentral\                  MeshCentral 1.2.4 (agents\ pruned to Windows x64)
        meshcentral-data\        JUNCTION -> C:\Ring\RemoteView\meshcentral-data\
        meshcentral-recordings\  JUNCTION -> C:\Ring\RemoteView\meshcentral-recordings\
    meshcentral-data\             real folder: config.json, certificates, database
    meshcentral-recordings\       real folder: session .mcrec recordings
    manifest.json                 the payload SBOM
    payload-inventory.json        path + size + SHA256 of every payload file
    cloudflared\                  v2 only — see below
```

**The junctions are load-bearing — do not delete them.** MeshCentral resolves
its data folder relative to the package location, and `winservice.js`
forwards only `user/port/aliasport/mpsport/mpsaliasport/redirport/exactport/debug`
to the Windows service — not `--datapath`. Deletes in this package are
junction-aware: a link is unlinked first, so a recursive delete can never
follow it into real data.

| Item | Detail |
|---|---|
| Certificates | `meshcentral-data\` holds root / webserver / agentserver / codesign / mps cert+key pairs, generated by MeshCentral itself on first start (no synchronous `--cert` step). `webserver-cert-public.crt`'s subject CN is the rendered certificate name — see [§9.5](#configuration-keys) — not the product name and not the bare NetBIOS name. |
| Recordings | Capped **90 days / 500 files / 20,480 MB**, scoped to `Plant HMI` only, indexed, protocol `2` (desktop) only. Evidence — fold into the plant's backup/retention policy rather than pruning ad hoc. |
| Shortcuts | `...\Start Menu\Programs\Ring Remote View.url` and `C:\Users\Public\Desktop\Ring Remote View.url`, both to `https://<hostname>:8443/`. |
| v2: `cloudflared\` | `cloudflared.exe` (pinned, SHA256-verified) and `install-state.json` (version, SHA256, hostname, patched-key list, edge evidence, a non-reversible token fingerprint — never the token itself). |
| v2: config backups | `meshcentral-data\config.json.bak-<yyyyMMddHHmmss>`, written before every v2 patch. Never pruned. |

**Rollback deny-list.** `C:\Ring\Icons`, `C:\Ring\Pictures`, `C:\Ring\App` and
`C:\Ring` itself can never be deleted by a rollback — only artefacts *this
run* created (install root, junctions, `config.json`, both services, the
firewall rule, the shortcuts, and any accounts it created) are ever undone.

---

## 9.4 Accounts and rights {#accounts-and-rights}

**The rule the design depends on:** a MeshCentral **site administrator
bypasses every device-group rights mask entirely.** So every operator is
created with **`--rights none`** — zero *server* rights — and access is
granted only on the `Plant HMI` device group, per account, by numeric mask.
The admin account stays with plant management, never handed to an operator.

| Account type | Server rights | Device-group mask | Can drive? | Terminal/files/registry/software |
|---|---|---|---|---|
| Site administrator | Full (`siteadmin = 4294967295`, i.e. `0xFFFFFFFF`) | n/a — bypasses masks | yes | yes — exactly why this never goes to an operator |
| Operator, **control** | `none` | **12586504** | yes | denied |
| Operator, **view-only** (default) | `none` | **12586760** | no — input discarded | denied |

```
   8         REMOTECONTROL
 512         NOTERMINAL
1024         NOFILES
2048         NOAMT
4194304      NOREGISTRY
8388608      NOSOFTWARE
--------
12586504     control mask
    +256     REMOTEVIEWONLY
--------
12586760     view-only mask
```

Bit **values** are the `MESHRIGHT_*` constants in `meshuser.js` @ 1.2.4,
lines 40–64. MeshCtrl **flag names** map to the same bits in `meshctrl.js`
@ 1.2.4, `AddUserToDeviceGroup`, lines 1648–1665: control =
`--remotecontrol --noterminal --nofiles --noamt --noregistry --nosoftware`;
view-only = the same **plus** `--desktopviewonly` (the flag for the 256 bit
— `--viewonly` is **not** a real flag).

**Erratum, corrected on purpose.** An earlier brief quoted `12587016` /
`12587272` — the same flags, mis-added by exactly 512. This package ships
the correct masks above, and the static gate fails the build if either
superseded literal reappears anywhere.

**What MeshCtrl actually shows** (`ListUsersOfDeviceGroup`, bench-observed):
`ringadmin` → `FullAdministrator`; `jbaker` (control) →
`RemoteControl, NoTerminal, NoFiles, NoAMT`; `shiftlead` (view-only) →
`RemoteControl, RemoteViewOnly, NoTerminal, NoFiles, NoAMT`. **Note the
gap:** MeshCtrl's text view never names the `NoRegistry`/`NoSoftware` bits —
only the numeric mask read-back (12586504/12586760) confirms them.

**Enforced server-side, not just a hidden tab.** The 2026-09-04 pen-test pass
([§9.7](#verification-evidence)) confirmed the relay itself refuses to route
a restricted account to the agent for terminal, file and command requests.

**2FA is domain-wide, not per-account.** `force2factor: true` applies to
every account — MeshCentral 1.2.4 has no per-account 2FA setting. It is
enforced **post-login**: a successful password login is signalled to force
2FA *enrolment* afterward, not refused outright (`webserver.js`, feature flag
`0x00040000`). `skip2factor` is deliberately **absent**.

---

## 9.5 Configuration keys as rendered by the installer {#configuration-keys}

Rendered by `Install-RemoteView.ps1` from `config-template.json` into
`meshcentral-data\config.json`. `<TOKEN>` = a placeholder.

### `settings.*`

| Key | Rendered value | Why |
|---|---|---|
| `cert` | `<HOSTNAME>` — **lowercase, WITH A DOT**: the FQDN if DNS gives one, else `<hostname>.local` | MeshCentral 1.2.4 only offers 2FA enrolment in the UI when `certificates.CommonName` contains a dot (`webserver.js`, feature flag `0x00001000`). A bare NetBIOS name never has one, so nobody could ever enrol while `force2factor` blocked every feature until they did — a dead lock. Fixed on branch `docs/remote-access-guide`; bench-rendered as `laptop-t8d4hur8.local`. |
| `npmpath` | absolute path to the bundled `node\node_modules\npm\bin\npm-cli.js` | Belt-and-suspenders: if MeshCentral ever falls to its startup module-install path it must use the bundled npm, never a missing machine-wide one. |
| `LANonly` | `true` | No tunnel, no internet exposure by default. Also makes the served UI build WebSocket URLs from `window.location.hostname`, not the cert CN — load-bearing **for** v2, not just a v1 default. |
| `port` / `redirPort` | `<HTTPS_PORT>` (8443) / `0` | The web UI port; no plaintext listener exists at all. |
| `mpsPort` / `mpsPortBind` | `4433` / `"127.0.0.1"` | See [§9.1](#pinned-constants)/[§9.2](#listeners-and-firewall) — never reachable off-box; in LAN-only mode never even opens a socket. |
| `exactPorts` | `true` | Refuses to silently walk to another port on a collision — loud failure, not a surprise listener. Also required by v2: cloudflared's origin hard-codes `127.0.0.1:8443`. |
| `userAllowedIP` / `agentAllowedIP` | `<LAN_CIDR>,127.0.0.1` | The source-IP gate — set again under `domains[""]` below; both are independent and must move together. |
| `selfUpdate` / `noAgentUpdate` | `false` / `1` | A validated HMI PC does not silently take new server or agent code. |
| `allowLoginToken` | `false` | No login-token bypass of password/2FA. |
| `allowFraming` | `false` | The UI is never framed by another page. |
| `webRTC` | `false` | This is *why* the "Mesh Agent WebRTC Traffic" rule ([§9.2](#listeners-and-firewall)) is unused even though it exists. |
| `plugins.enabled` | `false` | No plugin system. |
| `maxInvalidLogin` / `maxInvalid2fa` | `{time:10, count:5, coolofftime:10}` each | Brute-force throttle on password and 2FA attempts. |

### `domains[""].*`

| Key | Rendered value | Why |
|---|---|---|
| `newAccounts` | `false` | Self-registration off. Bench-confirmed: `/createaccount`, `/register`, `/signup` all HTTP 404, no form. |
| `newAccountsRights` | `notools, nonewgroups, nonewdevices, locksettings` | Dormant while `newAccounts` is false — documents intent if ever flipped. |
| `guestDeviceSharing`, `agentSelfGuestSharing`, `allowSavingDeviceCredentials`, `ipkvm`, `mstsc`, `novnc`, `ssh`, `myServer`, `agentInviteCodes`, `localSessionRecording` | all `false` | Every unused feature is off, not merely unused. |
| `userAllowedIP` / `agentAllowedIP` | `<LAN_CIDR>,127.0.0.1` | The **second, independent** copy — `webserver.js` evaluates the global and per-domain lists separately. |
| `allowedOrigin` | `<ALLOWED_ORIGINS>` — lowercase array: bare hostname, certificate name, `localhost`, `127.0.0.1`, primary LAN IPv4 | MeshCentral closes `/control.ashx` with cause `invalidorigin` unless the browser's Origin hostname is listed here — or, with no list, unless it equals the certificate CommonName **case-sensitively**, which a lowercase browser Origin never matches on an uppercase Windows machine name. This was a real, bench-observed blocking defect before the fix — see [§9.8](#security-posture). |
| `userSessionIdleTimeout` / `logoutOnIdleSessionTimeout` | `30` / `true` | Idle browser sessions log out. |
| `clipboardGet` / `clipboardSet` | `false` / `false` | No clipboard bridging between browser and HMI desktop. |
| `passwordRequirements.min/upper/lower/numeric/nonalpha` | `12/1/1/1/1` | Password complexity floor. |
| `passwordRequirements.banCommonPasswords` / `oldPasswordBan` | `true` / `5` | Common-password ban (triggers the bundled `wildleek` module); no reusing the last five. |
| `passwordRequirements.force2factor` | `true` | Domain-wide, post-login — see [§9.4](#accounts-and-rights). |
| `passwordRequirements.otp2factor` / `backupcode2factor` | `true` / `true` | The only two 2FA methods enabled — a plant LAN has no SMTP/SMS/Duo reachability, so any other method would offer enrolment paths that can never complete (all left `false`). |
| `passwordRequirements.skip2factor` | **deliberately absent** | Its presence would let an IP range bypass 2FA entirely. |
| `desktop.viewonly` / `disableconnectall` | `false` / `true` | Control is granted **per account** via the rights mask, never domain-wide; no bulk "connect to all" action. |
| `terminal.sshConnect` / `files.sftpConnect` | `false` / `false` | No SSH/SFTP bridging on top of the already-denied tabs. |
| `userConsentFlags.desktopnotify` / `desktopprompt` | `true` / `true` | Matches the device-group numeric consent value `9` (1 + 8). |
| `consentMessages.Desktop` | `"{0} ({1}) is asking to view or take control of this Ring HMI. Allow?"` | The exact text shown at the panel. |
| `consentMessages.Terminal` / `Files` | `"...This should never appear - deny it and report it."` | If either ever appears, the rights model has failed — an incident, not a click-through. |
| `consentMessages.consentTimeout` | `60` (seconds) | How long the panel has to answer. |
| `consentMessages.autoAcceptOnTimeout` / `autoAcceptIfNoUser` / `autoAcceptIfLocked` | all `false` | **An unattended panel denies the connection.** Bench-confirmed: after 61 s unanswered, the session returned to Disconnected with no screen ever shown. |
| `agentConfig` | `["disableUpdate=1", "noUpdateCoreModule=1"]` | The local agent is frozen — no silent code change on a validated HMI PC. |
| `sessionRecording.*` | scoped `true`, indexed, `protocols:[2]`, `90` days / `500` files / `20480` MB | Desktop only, `Plant HMI` only, capped as shown. |
| `limits.*` | `MaxDevices 8, MaxUserAccounts 25, MaxUserSessions 16, MaxAgentSessions 8` | Sane ceilings for a single-HMI deployment. |

### v2 — the five keys it patches, and why {#v2-config-keys}

Each is justified line-by-line against the MeshCentral 1.2.4 source in
`REMOTE_ACCESS_V2_RUNBOOK.md` §4A; this is the one-line summary.

| # | Key | Rendered value | Reason |
|---|---|---|---|
| 1 | `settings.trustedProxy` | `"127.0.0.1,::1"` | The **only** key that makes MeshCentral trust cloudflared's forwarded `cf-connecting-ip`, so the audit log and login-throttle bucket use the real client IP instead of `127.0.0.1` for every tunnel login. |
| 2 | `settings.cookieIpCheck` | `"lax"` | Explicit (matches the 1.2.4 default) so the behaviour is documented, not discovered — accepts a `/24` move once `trustedProxy` is on, forces re-login on a bigger one. |
| 3 | `settings.userAllowedIP` | Widened with approved off-site CIDRs, **or** removed via `-NoUserIpAllowList` | A deliberate Section-2D decision — see [§9.8](#security-posture). Widened, the LAN CIDR and `127.0.0.1` are preserved, never replaced. |
| 4 | `domains[""].userAllowedIP` | Same change, again | Independent of #3 — both must move together or logins die before authentication runs. |
| 5 | `domains[""].allowedOrigin` | Widened: public FQDN + this machine's names + loopback | Without the public hostname here, `/control.ashx` — every desktop session depends on it — closes with `invalidorigin`. |
| — | `_ringRemoteViewV2` | Documentation-only array | Underscore-prefixed top-level keys are ignored by MeshCentral; a review aid only. |

### v2 — must NOT set (the installer refuses to proceed if any is present)

| Key | What it would do |
|---|---|
| `settings.tlsOffload` (any value) | Breaks v2 outright — MeshCentral serves plain HTTP on 8443, cloudflared's `https://` origin fails; also drops the cookie's `Secure` flag. |
| `settings.trustedProxy = "CloudFlare"` | Wrong for a same-machine tunnel — matches the *TCP peer* against Cloudflare's ranges, but the peer here is `127.0.0.1`, so `cf-connecting-ip` is silently ignored. |
| `settings.trustedProxy = true` | Trusts forwarded headers from **every** peer — any LAN host could forge `X-Forwarded-For` past `userAllowedIP`. Also out of schema (typed `string`). |
| `settings.aliasPort` (e.g. `443`) | Poisons LAN agent discovery — the scanner tells the local agent to connect to 443, where nothing listens. |
| `domains[""].certUrl` | Solves a non-problem; causes outbound cert fetches and agent churn on every Cloudflare edge-cert rotation. |
| `ignoreAgentHashCheck` (either scope) | Disables the agent's TLS pinning — "for debugging only" per the schema's own text. |
| `domains[""].dns = <public fqdn>` | On the default domain this takes the **whole server offline** — `dns` is for extra sub-domains, never `""`. |
| `settings.relayDNS = <public fqdn>` | Replaces the MeshCentral UI with the app web-relay for exactly the hostname you meant to serve. |
| `settings.LANonly=false`, `settings.WANonly=true` | Breaks the tunnel; `WANonly` also kills local-relay and the LAN discovery scanner. |
| `settings.strictTransportSecurity` | Not in the 1.2.4 schema at all, and locks LAN users out of the self-signed cert's click-through. |
| `allowFraming` variants | Unnecessary (Access uses a full-page redirect, not an iframe) and widens the CSP `frame-ancestors` surface. |
| `domains[""].allowedOrigin = true` | Disables the origin gate entirely instead of enumerating hostnames. |
| `settings.cookieIpCheck = "none"` | Unbinds cookies from the client IP just as `trustedProxy` makes that IP meaningful. |
| `agentPort`/`agentAliasPort`/`agentAliasDNS` | Opens a second agent-only listener — there is exactly one, local agent, and it already reaches 8443. |

---

## 9.6 Scripts and parameters {#scripts-and-parameters}

### `Build-RemoteViewPackage.ps1` — packager-side payload builder

| Parameter | Default | Meaning |
|---|---|---|
| `-OutDir` | `<script dir>\dist` | Build output directory. |
| `-NodeMajor` | `24` | Node LTS major to stage. Fails if past end-of-life. |
| `-ProbeOnly` | off | Resolve + HEAD-probe URLs, then stop. Downloads nothing. |
| `-SkipZip` | off | Stage `dist\payload` + manifest, skip the `.zip`. |
| `-Force` | off | Rebuild from scratch. |

### `Install-RemoteView.ps1` — target-side installer

| Parameter | Default | Meaning |
|---|---|---|
| `-AdminUser` | `ringadmin` | Site administrator account id. |
| `-AdminPassword` | generated | SecureString; omitted, a 20-char password is shown once. |
| `-AdminPasswordFile` | — | UTF-8 file, first line = password; deleted after reading. |
| `-LanCidr` | auto-detected | Allowed LAN range. Required with `-InterfaceAlias` on a multi-homed box. |
| `-InterfaceAlias` | — | Office NIC the firewall rule binds to; required on a multi-homed box. |
| `-HttpsPort` | `8443` | Pinned. |
| `-Operators` | `@()` | `@{User='...'; Pass='...'; ViewOnly=$true/$false}` array. |
| `-OperatorSpec` | — | Flat form: `"jbaker:control,shiftlead:view"` (defaults view-only). |
| `-PayloadZip` | `<script dir>\dist\RingRemoteView-payload.zip` | Never expanded with `Expand-Archive` — extracted entry by entry and proven against the archive's own listing and `payload-inventory.json` before anything is copied. |
| `-PayloadDir` | — | Alternative: an already-expanded directory. |
| `-InstallRoot` | `C:\Ring\RemoteView` | Pinned. |
| `-NoFirewall` | off | Skip the inbound rule. |
| `-NoAgent` | off | Skip the local Mesh Agent. |
| `-Reinstall` | off | Adopt-and-replace an existing service instead of aborting. |
| `-TempExpandRoot` | `$env:TEMP` | Payload expansion directory. |
| `-VerifyPayloadHashes` | off | Re-hash every extracted file (adds minutes under on-access AV). |
| `-HealthTimeoutSeconds` | `180` | Wait for `https://127.0.0.1:<port>` to answer. |

### `Provision-RemoteView.ps1` — device group + operator accounts

| Parameter | Default | Meaning |
|---|---|---|
| `-InstallRoot` / `-HttpsPort` | `C:\Ring\RemoteView` / `8443` | |
| `-AdminUser` / `-AdminPassword` | mandatory | Site administrator id / SecureString. |
| `-Operators` | `@()` | Same shape as the installer's. |
| `-GroupName` | `Plant HMI` | Pinned. |
| `-EmitInviteLink` | off | Time-bounded background-agent invite link — off by default (a never-expiring link is a standing credential). |
| `-InviteLinkHours` | `24` | Must be `> 0`. |

### `Uninstall-RemoteView.ps1`

| Parameter | Default | Meaning |
|---|---|---|
| `-InstallRoot` / `-HttpsPort` | `C:\Ring\RemoteView` / `8443` | Port is used to find the firewall rule by name. |
| `-KeepData` | off | Preserve `meshcentral-data\` and `meshcentral-recordings\`. |
| `-Force` | off | No confirmation prompt. |

### `New-RemoteViewFirewallRule.ps1`

| Parameter | Default | Meaning |
|---|---|---|
| `-Port` | `8443` | Pinned. |
| `-InterfaceAlias` | — | Restrict to one NIC. |
| `-Remove` | off | Remove instead of create. |

### `Install-RemoteViewTunnel.ps1` — v2 companion

| Parameter | Default | Meaning |
|---|---|---|
| `-TunnelToken` | — | Tunnel token (String/SecureString). Required unless `-TunnelTokenFile` or `-Uninstall`. Stored in the service ImagePath — an accepted residual. |
| `-TunnelTokenFile` | — | UTF-8 file, first line = token; deleted after reading. |
| `-Hostname` | — | Public FQDN the tunnel publishes — no scheme/port/path. |
| `-RemoteAllowedIP` | `@()` | Approved off-site egress CIDRs, **added** to the v1 allow-list. Choose this or `-NoUserIpAllowList` — the installer refuses to guess. |
| `-NoUserIpAllowList` | off | Removes `userAllowedIP` entirely; Access becomes the whole perimeter, LAN users lose the source-IP gate too. |
| `-ExtraAllowedOrigin` | `@()` | Extra bare hostnames for `allowedOrigin`. |
| `-CloudflaredVersion` | `2026.7.3` | Changing it **requires** `-ExpectedSha256`. |
| `-ExpectedSha256` | pinned hash | No way to skip verification. |
| `-PayloadPath` | — | Already-downloaded binary (air-gapped) — still hash-verified. |
| `-InstallRoot` / `-HttpsPort` | `C:\Ring\RemoteView` / `8443` | v1 root and port. |
| `-EdgeWaitSeconds` | `120` | Bounded wait for evidence of a real edge connection. |
| `-AllowUnconfirmedEdge` | off | Downgrades "no edge evidence" to a warning — never suppresses an observed failure. |
| `-HealthTimeoutSeconds` | `180` | Matches v1. |
| `-Uninstall` / `-Force` | off / off | Remove the service, revert only the keys v2 added / no confirmation prompt. |

### `Test-RemoteViewTunnelSmoke.ps1` — v2 staged smoke

| Parameter | Default | Meaning |
|---|---|---|
| `-CloudflaredExe` | staged path, else PATH | Path to `cloudflared.exe`. |
| `-HttpsPort` | `8443` | Origin is always `https://127.0.0.1:<port>`. |
| `-BudgetSeconds` | `240` | Hard ceiling on the exposure window (raised from 120 after a bench-found defect — [§9.7](#verification-evidence)). |
| `-TunnelStartTimeoutSeconds` | `45` | Wait for cloudflared to print its ephemeral hostname. |
| `-EvidenceDir` | `<script dir>\dist` | Transcript location (git-ignored). |
| `-NoDelay` | off | Skip the 5 s abort window on the exposure banner. |

### `Verify-RemoteViewStatic.ps1` — static gate

`param()` — **no parameters.**

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\test\Verify-RemoteViewStatic.ps1
```

Runs entirely offline: installs nothing, starts no service, touches no
firewall. See [§9.7](#verification-evidence) for the current pass result.

---

## 9.7 Verification evidence {#verification-evidence}

Everything here was observed, in order, on a bench pass **2026-09-04** on the
developer laptop (Windows 11 Home, single NIC classified **Public**), source
tree `main @ fd4e5173`. Items not exercised are marked **NOT VERIFIED** —
that phrasing is deliberate; do not read it as a pass.

| Check | Result |
|---|---|
| Static gate, before the certificate/`allowedOrigin` fix | PASS, 0 failures |
| Static gate, after the fix (branch `docs/remote-access-guide`) | PASS, 0 failures — see `remote-view/test/Verify-RemoteViewStatic.ps1` output for the current check count |
| `Build-RemoteViewPackage.ps1 -ProbeOnly` | PASS — MeshCentral pin 1.2.4 confirmed at npm; Node resolved to v24.20.0 (EOL 2028-04-30); nodejs.org URLs HEAD 200 |
| Existing payload `RingRemoteView-payload.zip` (built 2026-07-31) | 115,737,143 bytes, SHA-256 `be01f3ca00c84ce18df9e802bda82f03ab7a7a3774104aa4ebcdaa929bf2191f`, matches `manifest.json` (Node v24.18.1, MeshCentral 1.2.4, four extra modules) |

**Fresh install with the fixed package (authoritative).**
`Install-RemoteView.ps1` (`-NoFirewall`, with the local Mesh Agent), branch
`docs/remote-access-guide`: **exit 0, 175 s** (an earlier run before the
cert/origin fix took 203 s under the same conditions — expect longer on an
HMI PC with on-access AV scanning the ~394 MB payload). Printed: certificate
name `laptop-t8d4hur8.local`; `allowedOrigin` = `laptop-t8d4hur8,
laptop-t8d4hur8.local, localhost, 127.0.0.1, 192.168.1.206`; server cert
`subject=CN=laptop-t8d4hur8.local`; agent service Running; shortcuts for
`https://laptop-t8d4hur8:8443/`.

### Browser walkthrough as operator `jbaker` (control mask) — observed and screenshotted

| Step | Observed |
|---|---|
| Login page | Title "Ring Remote View - Login"; footer credits MeshCentral 1.2.4 (Apache-2.0). |
| First login, one-time password | "Password change requested," two password fields, then the main UI loads. |
| 2FA enrolment | My Account → **"Account security" → "Manage authenticator app"**: QR code, secret, 6-digit token field. A valid code: "Authenticator app activation successful." |
| Next login | A second page asks for the 6-digit token. |
| Device list | Group **Plant HMI**, device **LAPTOP-T8D4HUR8**, "Agent, Powered." Operator tabs: **General, Desktop, Events only.** |
| Desktop → Connect | "Connected"; area reads **"Waiting for user to grant access..."** At the panel: **"jbaker (jbaker) is asking to view or take control of this Ring HMI. Allow?"** (Allow / Deny, plus an "auto-accept next 5 minutes" checkbox). |
| Nobody answers | After **61 s**, status returns to **Disconnected**; no screen content was ever shown. **PASS — fails closed.** |

Not exercised: clicking Allow, view-only input-discard, registry/software
tabs, off-subnet. The auto-accept checkbox is stock MeshCentral — tell
operators to leave it unticked unless the shift agrees.

**Uninstall — verified.**
`Uninstall-RemoteView.ps1 -Force`: exit **0**, **50 s**. Removed the Mesh
Agent (`-fulluninstall`) and MeshCentral service, printed "No Ring Remote
View firewall rules found" (this run used `-NoFirewall`), "UNINSTALL
COMPLETE." Confirmed afterward: no services, install root gone,
`C:\Program Files\Mesh Agent` gone, **the "Mesh Agent WebRTC Traffic" rule
gone**, no 8443 listener, shortcuts gone.

### Penetration-test items — `REMOTE_ACCESS_RUNBOOK.md` §8, observed via MeshCtrl

| # | Attack | Account | Observed | Verdict |
|---|---|---|---|---|
| 2 | File transfer (Upload/Download) | jbaker (control) | `Connecting...` then **`Unable to route`**; nothing transferred | DENIED |
| 2 (control case) | same | ringadmin (admin) | Connected, download completed | allowed, as expected |
| 3 | `RunCommand --run whoami` | jbaker / shiftlead | **`Access denied`** (both) | DENIED |
| 3 (control case) | same | ringadmin | `OK` | allowed, as expected |
| 4 | `Shell` (interactive terminal) | jbaker | `Connecting...` then **`Unable to route`**; no shell in 20 s | DENIED |
| server rights | `ListUsers` | jbaker | **`Access denied`** (`--rights none`) | DENIED |
| 9 | Self-registration | unauthenticated | **HTTP 404** each, no form | DENIED |
| 10 | MPS exposure | — | no listener on 4433 | PASS |
| 7 | Consent bypass / unattended panel | — | confirmed in the walkthrough: 61 s timeout, Disconnected, no content shown | **PASS — fails closed** |
| 1 | Raw terminal relay (`p=1`) | — | not run raw; test #4 exercises the same relay gate | partially covered |
| 5 | Registry / software tabs | — | **NOT VERIFIED** — no headless probe available | NOT VERIFIED |
| 6 | View-only sends input | — | **NOT VERIFIED** headlessly | NOT VERIFIED |
| 8 | Off-subnet reach | — | **NOT possible** on a single machine | NOT VERIFIED |

**Rights masks are enforced server-side** — the relay itself refuses to
route a restricted account, not merely a hidden tab. **The full §8 table on
a clean VM with a second machine is still owed for items 1, 5, 6 and 8.**

**v2 — what was verified:**

| Item | Result |
|---|---|
| `cloudflared-windows-amd64.exe` 2026.7.3 | 54,213,360 bytes, SHA-256 `8635DA433B6DF8194746E88ED9D2589566C20E38BFC2A80E431A348B7C765841` — **matches the pin**; `--version` confirms the tag. |
| `Test-RemoteViewTunnelSmoke.ps1` (after the fix below) | **PASS — every gated probe passed, 59 s.** Hostname resolved after 30 s; `GET /` HTTP 200; CSP `connect-src` named the tunnel's own hostname (Host header survives the hop); `/control.ashx`, `/meshrelay.ashx`, `/2fahold.ashx`, `/echo.ashx` upgrades all PASS; the Origin gate closed an unknown Origin with `invalidorigin`; tunnel process confirmed gone at the end. |
| `Install-RemoteViewTunnel.ps1` itself | **NOT run** — needs a real Zero Trust tunnel token and domain. |
| Cloudflare Access policy, full off-site smoke, v2 penetration test | **OUTSTANDING.** |

**Defect found and fixed in the smoke script.** As shipped, it probed the new
hostname before DNS had propagated and every probe failed with an opaque
exception. Fixed on branch `docs/remote-access-guide`: waits up to 90 s for
DNS, retries the login-page GET for up to 60 s through transient Cloudflare
530/1033 errors, unwraps the real inner exception, and raised the default
`-BudgetSeconds` from 120 to **240**. The PASS above is the re-run.

**Other verified facts.**
The Inno Setup GUI wrapper (`RingRemoteView.iss`) has **never been
compiled** — no ISCC installed, no `RingRemoteView-Setup.exe` anywhere in the
tree; **the PowerShell installer is the only proven install path.**
Multi-homed detection ignores loopback and `169.254.*` APIPA addresses.
**IPv6 caveat:** browsing the bare hostname from the laptop itself answered
**HTTP 401 before any login page**, because the name resolved to IPv6
addresses first and `userAllowedIP` lists only the IPv4 CIDR and
`127.0.0.1` — make sure the HMI name resolves to IPv4 for LAN clients, or
browse the IPv4 address directly (`allowedOrigin` permits that). **A
PowerShell trap, not MeshCentral's:** `Invoke-WebRequest`/`ClientWebSocket`
with a `ServerCertificateValidationCallback` scriptblock fails
non-interactively ("There is no Runspace available to run scripts in this
thread") — use `curl.exe -k https://127.0.0.1:8443/` or a real browser for a
hand check instead.

---

## 9.8 Security posture and residual risks {#security-posture}

**Defence in depth**, any one gate stopping an attacker on its own: (1) the
network perimeter — LAN-only (v1), or Cloudflare Access authenticating at
the edge (v2); (2) MeshCentral's own accounts — login, `force2factor`, the
panel consent prompt, session recording; (3) the rights mask, enforced
server-side (confirmed in [§9.7](#verification-evidence)).

**Fixed defects (bench 2026-09-04 — read before trusting an unmodified `main` build):**

| Defect | Root cause | Fix |
|---|---|---|
| Login succeeded, then "Invalid origin in HTTP request, click to reconnect." | `settings.cert` was `$env:COMPUTERNAME` (always uppercase); the Origin check is case-sensitive and browsers always lowercase the Origin. | Cert name rendered lowercase; `allowedOrigin` added (hostname, cert name, localhost, 127.0.0.1, LAN IPv4). |
| 2FA enrolment was **impossible** while `force2factor` made it **mandatory**. | MeshCentral only shows "Account Security" when the cert CommonName contains a dot; a bare NetBIOS name never does. | Cert name rendered **with a dot** (FQDN, or `<hostname>.local`). |
| A failed install sometimes printed `ROLLBACK INCOMPLETE` for folders that had actually been removed. | The rollback closures for `meshcentral-data`/`meshcentral-recordings` used `.GetNewClosure()` blocks that couldn't see the script's own `Remove-TreeRobust` function. | The function is now captured into the closure. |

All three are fixed on branch `docs/remote-access-guide` (the static gate
gained one new assertion for the fix). **Verify which branch a given build
came from** before
assuming 2FA enrolment or a plain-hostname browser session will work.

**Residual risks — v1.**
MeshCtrl takes credentials on its command line during provisioning, briefly
visible in the process list (console-only, install-time only); the Inno
wrapper passes `-AdminPassword` as an argument — same residual; a site
administrator can do everything, and the control is procedural — that
credential stays with plant management.

**Residual risks — v2 (only where the tunnel is installed).**
The tunnel token is a standing credential in the service ImagePath
(registry) — rotate it in the Zero Trust dashboard if the box is suspect.
`trustedProxy: "127.0.0.1,::1"` means any **local** process can forge a
source IP — accepted only because `cloudflared` is the sole local client of
8443 on a single-purpose HMI, and `force2factor` still stands behind it.
Cloudflare sits in the trust path (edge-decrypt, re-encrypt to origin) — the
Tailscale alternative avoids this if policy forbids a third party. Double
authentication is intentional: MeshCentral has no key consuming an Access
identity header, so Access is a perimeter, not single sign-on.
`cookieIpCheck: "lax"` forces re-login on a roaming IP move outside its
`/24` — correct behaviour, not a fault to chase. The v2 install restarts
MeshCentral, dropping any live LAN session; Ring and the PLC are untouched.

**Still outstanding — a verification gap, not a residual risk.**
Pen-test items 1 (partial), 5, 6, 8 on a clean two-machine VM; the entire v2
off-site chain (Access policy, off-site smoke, v2 penetration test); whether
the Inno Setup wrapper compiles at all.

---

## 9.9 Patch policy and licences {#patch-policy-and-licences}

| Component | Pin mechanism | To move it |
|---|---|---|
| MeshCentral | Pinned in `Build-RemoteViewPackage.ps1`; `selfUpdate:false`; agent frozen (`agentConfig`, `noAgentUpdate:1`) | Watch advisories. On a security release: bump the pin, rebuild, re-run the static gate, **re-run the penetration test**, redeploy in a window. Never `npm update` in place. |
| Node runtime | The build refuses a line already past EOL | Bump `-NodeMajor` as the pinned line nears EOL. |
| `cloudflared` (v2) | Pinned version + SHA256 in the installer; auto-update off in the service ImagePath | `-Uninstall`, reinstall with new `-CloudflaredVersion` **and** `-ExpectedSha256` (refuses a bump without it); re-run the staged and relevant off-site smoke lines. |
| Access policy (v2) | A live policy, not a pinned artefact | Review allowed emails/groups, session duration and MFA on the same cadence as user off-boarding — a drifted policy is a silent hole. |

**AV/EDR.** MeshCentral is widely flagged as a post-compromise RMM tool —
support an allow-list request with `dist\manifest.json` (the SBOM). Concrete
example: this laptop's Defender history shows `MeshService.exe` quarantined
2026-08-20 as RMM tooling; the 2026-09-04 install needed a Defender
exclusion for the scratch directory and `C:\Program Files\Mesh Agent` (removed
after). `cloudflared.exe` is legitimately signed but some EDR flags it by
category too — raise both with IT before install day.

| Licence file | Contents |
|---|---|
| `remote-view\NOTICE` | Attribution + Apache-2.0 statement of changes. |
| `remote-view\THIRD-PARTY-LICENSES.txt` | Apache-2.0 full text, Node.js MIT notice, cloudflared licence (also Apache-2.0). |
| `dist\manifest.json` | The SBOM — the artefact an AV/EDR allow-list request should cite. |

---

## Sources verified

- `remote-view\README.md` — pinned constants, layout, security model, v2
  summary, licence and patch-policy sections.
- `remote-view\Install-RemoteView.ps1` — parameter block; the certificate
  name/`allowedOrigin` rendering logic and comments; the INSTALL OK summary;
  the rollback/catch-block structure.
- `remote-view\Provision-RemoteView.ps1`, `remote-view\Uninstall-RemoteView.ps1`,
  `remote-view\New-RemoteViewFirewallRule.ps1` — read in full: parameter
  blocks, mask constants, password generation, removal sequence.
- `remote-view\Install-RemoteViewTunnel.ps1`, `remote-view\Test-RemoteViewTunnelSmoke.ps1`,
  `remote-view\Build-RemoteViewPackage.ps1`, `remote-view\test\Verify-RemoteViewStatic.ps1`
  — `.SYNOPSIS`/`.DESCRIPTION` and parameter/pinned-constants blocks of each,
  including the v2 cloudflared version/SHA256 pins.
- `remote-view\config-template.json` — every rendered key in §9.5 and the
  `_keyProvenance`/`_notes` blocks behind the "why" column.
- `docs\production-readiness\REMOTE_ACCESS_RUNBOOK.md` — v1 procedure,
  firewall table, account model and mask arithmetic, §8 penetration table,
  patch cadence.
- `docs\production-readiness\REMOTE_ACCESS_V2_RUNBOOK.md` — v2 procedure,
  the five patched keys with their MeshCentral evidence, the must-not-set
  table, staged/off-site smoke tables, v2 penetration test, residual risks.
- `docs\production-readiness\REMOTE_ACCESS_V2_CLOUDFLARE.md` — design
  rationale, the three-gate argument, the Tailscale alternative.
- Bench verification facts sheet dated 2026-09-04 (developer laptop
  LAPTOP-T8D4HUR8, source tree `main @ fd4e5173`) — the source for every
  dated observation and pass/fail result in §9.2, §9.6–§9.8; where a later
  addendum in it superseded an earlier line, this chapter follows the addendum.

Not verified anywhere in this chapter: pen-test items 1 (fully), 5, 6 and 8
from `REMOTE_ACCESS_RUNBOOK.md` §8; the entire v2 off-site chain; and
whether the Inno Setup wrapper compiles at all (never built).
