Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fd00664
feat(web): add server-only web host (Phase 20 slice A)
keyldev Jul 20, 2026
10a3ea4
feat(web): minimal auth behind IWebAuthProvider (Phase 20 slice 20.2)
keyldev Jul 20, 2026
d96f0c9
feat(web): read-only cameras API + lean backend (Phase 20 slice 20.3)
keyldev Jul 20, 2026
26a012d
feat(web): camera CRUD with validation + audit (Phase 20 slice 20.3)
keyldev Jul 20, 2026
d8b03ce
feat(web): camera groups CRUD + shared API helpers (Phase 20 slice 20.3)
keyldev Jul 20, 2026
9757772
feat(web): server-rendered UI shell — login + cameras (Phase 20 slice C)
keyldev Jul 20, 2026
7cf66b5
feat(web): honour reverse-proxy forwarded headers (Phase 20 slice 20.7)
keyldev Jul 20, 2026
8f750ec
feat(web): live H.264 video over WebSocket (Phase 20 slice 20.4 spike)
keyldev Jul 20, 2026
731c917
feat(web): multi-tile live grid 1/4/9 (Phase 20 slice 20.5)
keyldev Jul 20, 2026
1c2c827
fix(web): treat loopback aliases / ports as same-origin in CSRF check
keyldev Jul 20, 2026
36e1bf5
fix(web): stop rejecting login with Origin: null
keyldev Jul 20, 2026
c23b99a
feat(web): camera CRUD from the browser (Phase 20 slice 20.5)
keyldev Jul 20, 2026
147df72
feat(web): H.265 fallback via on-demand H.264 transcode (Phase 20 sli…
keyldev Jul 20, 2026
85076b1
feat(web): fan-out one ffmpeg session per camera to N viewers (Phase …
keyldev Jul 20, 2026
42aae37
feat(web): RU/EN localization for the web UI (Phase 20 slice 20.5)
keyldev Jul 20, 2026
e487a70
feat(web): match the desktop Console skin + left sidebar (Phase 20 po…
keyldev Jul 20, 2026
3ab8b6b
feat(web): System page + config export/import + session revoke (Phase…
keyldev Jul 20, 2026
a60314c
feat(web): React SPA web UI (Phase 21) replacing Razor as default
keyldev Jul 21, 2026
f3d1f62
polish(web): tidy up the SPA grid
keyldev Jul 21, 2026
e89f1da
docs: self-hosting guide for the web server
keyldev Jul 21, 2026
276da9a
docs: explain the embedded React build + link web-server guide from R…
keyldev Jul 21, 2026
ab7ae82
docs: Russian translation of the web-server self-host guide
keyldev Jul 21, 2026
80ae59f
feat(web): saved grid layouts in the browser console
keyldev Jul 21, 2026
9b7f536
polish(web): replace native prompt/confirm with in-app modals
keyldev Jul 21, 2026
9c9c886
polish(web): finish removing native dialogs from the SPA
keyldev Jul 21, 2026
ded4494
feat(web): single-camera page + mobile layout
keyldev Jul 21, 2026
4ef5766
web: PTZ from the browser, and drop the Razor UI
keyldev Jul 21, 2026
ac20c2a
web: bundle Inter, paginate layouts
keyldev Jul 21, 2026
6e41d04
web: keep video playing across route changes
keyldev Jul 21, 2026
ec27968
web: find and add cameras from the browser
keyldev Jul 21, 2026
b6eeb57
web: users, permissions and per-user camera subsets
keyldev Jul 21, 2026
77c12dd
docs: web console gained PTZ, discovery and user permissions
keyldev Jul 21, 2026
822c7c1
docs: keep the self-host guide's wording public-facing
keyldev Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ jobs:
with:
dotnet-version: '9.0.x'

# Node for the React web SPA (Phase 21). The dotnet build/publish steps run
# an MSBuild target that invokes `npm ci` + `npm run build` and embeds the
# result into OpenIPC.Viewer.Web.dll, so Node must be present; cache keyed on
# the client lockfile keeps `npm ci` fast across runs.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: src/OpenIPC.Viewer.Web.Client/package-lock.json

# libsecret for the keyring; the bundled FFmpeg 7 .so come from the fetch
# step below, not apt — the distro ffmpeg (6.1 on 24.04 runners) is the
# wrong ABI for FFmpeg.AutoGen 7.1.1 and must NOT end up in the archive.
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ runtimes/android-x64/native/
# FFmpeg cross-compile scratch tree (build-ffmpeg-android.sh output)
build/ffmpeg-android-*/

# Web React SPA (Phase 21): node deps + the Vite build output that lands in the
# Web project's wwwroot. The MSBuild target rebuilds wwwroot on publish; the
# lockfile (package-lock.json) IS tracked.
node_modules/
src/OpenIPC.Viewer.Web/wwwroot/

## Local-only docs (planning, architecture, design-system, CLAUDE notes).
## Kept on disk for reference but not tracked.
/*.md
Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
<!-- Serves the embedded React SPA (Phase 21) from the Web assembly, so the
built front-end travels with the DLL regardless of which head hosts it. -->
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="9.0.0" />

<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageVersion Include="Dapper" Version="2.1.79" />
Expand Down
2 changes: 2 additions & 0 deletions OpenIPC.Viewer.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<Project Path="src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj" />
<Project Path="src/OpenIPC.Viewer.iOS/OpenIPC.Viewer.iOS.csproj" />
<Project Path="src/OpenIPC.Viewer.Video/OpenIPC.Viewer.Video.csproj" />
<Project Path="src/OpenIPC.Viewer.Web/OpenIPC.Viewer.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenIPC.Viewer.Analytics.Tests/OpenIPC.Viewer.Analytics.Tests.csproj" />
Expand All @@ -20,5 +21,6 @@
<Build Solution="Debug|*" Project="false" />
</Project>
<Project Path="tests/OpenIPC.Viewer.Video.Tests/OpenIPC.Viewer.Video.Tests.csproj" />
<Project Path="tests/OpenIPC.Viewer.Web.Tests/OpenIPC.Viewer.Web.Tests.csproj" />
</Folder>
</Solution>
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ Built with .NET 9 / 10 and Avalonia 12.
close-to-tray, light/dark themes with accent colors.
- **Camera groups**, English / Russian UI (runtime switch), responsive layout
(sidebar ↔ bottom tab strip).
- **Web console (self-host)** — run the same binary with `--server-only` for a
browser UI to your cameras over the LAN: live grid with saved layouts, PTZ,
network discovery, camera management, config backup, and accounts with
per-user permissions and camera subsets. No cloud. See the
[self-hosting guide](docs/web-server.md).

## Screenshots

Expand Down Expand Up @@ -102,6 +107,25 @@ a later release.
> Feedback is welcome — open an issue with OS version, what you did, and what
> happened.

## Web console (self-host)

The same binary can run headless as a small web server, serving a React
single-page UI for your cameras to any browser on your network — live grid with
saved layouts, PTZ, network discovery, and accounts with per-user permissions and
camera subsets. No cloud, no extra services:

```bash
export OPENIPC_WEB_ADMIN_PASSWORD='choose-a-strong-one'
./OpenIPC.Viewer.Desktop --server-only --lan --port 8787
# then open http://<this-machine-ip>:8787 and log in as admin
```

Localhost-only by default; `--lan` opts into network exposure. For an HTTPS
domain put it behind a reverse proxy. Full instructions — flags, adding and
finding cameras, users and permissions, ffmpeg, Caddy/nginx + systemd examples —
are in the self-hosting guide ([English](docs/web-server.md) ·
[Русский](docs/web-server.ru.md)).

## Building from source

```bash
Expand Down
259 changes: 259 additions & 0 deletions docs/web-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
# Self-hosting the OpenIPC Viewer web server

**English** · [Русский](web-server.ru.md)

The desktop app ships a headless **web server** mode: the *same binary*, launched
with `--server-only`, serves a browser console for your OpenIPC cameras over your
LAN. No cloud, no extra services — one admin password and a port.

* **Live grid** (1 / 4 / 9 / 16 / 25) with saved layouts and paging, per-camera
live view, fullscreen tiles. Video keeps playing while you move between pages.
* **Camera management** — add / edit / delete, groups, import/export config backup.
* **Find cameras on the network** — ONVIF, mDNS and an opt-in subnet sweep, then
add what you found without leaving the browser.
* **PTZ** — pan/tilt/zoom and presets for cameras that support it.
* **Users and permissions** — accounts with permission flags and, if you like, a
per-user subset of cameras.
* **H.264 over WebSocket** (fMP4 + MSE); H.265 is transcoded on the fly. One
ffmpeg session is shared across all viewers of a camera.
* Works from any browser on the network — phone, tablet, another PC.
* English / Russian, dark Console skin matching the desktop app.

It reads and writes the **same database as the desktop app**, so cameras you add
in one show up in the other.

---

## Quick start (LAN)

1. **Download** the release archive for your OS and unzip it
(`openipc-viewer-win-x64.zip`, `-linux-x64.tar.gz`, `-osx-arm64.tar.gz`),
or [build from source](#build-from-source).

2. **Pick an admin password** (otherwise a random one is generated and printed to
the log on every start — fine for a quick test, not for a stable deployment).

3. **Run it, bound to the LAN:**

**Linux / macOS**
```bash
export OPENIPC_WEB_ADMIN_PASSWORD='choose-a-strong-one'
./OpenIPC.Viewer.Desktop --server-only --lan --port 8787
```

**Windows (PowerShell)**
```powershell
$env:OPENIPC_WEB_ADMIN_PASSWORD = 'choose-a-strong-one'
.\OpenIPC.Viewer.Desktop.exe --server-only --lan --port 8787
```

4. **Open** `http://<this-machine-ip>:8787` from any device on the network and log
in as **`admin`** with the password you set.

Without `--lan` the server binds to `127.0.0.1` only (reachable from the same
machine) — the safe default. `--lan` binds `0.0.0.0` and prints a warning, because
there is no TLS at this layer; for anything beyond your trusted LAN, put it
[behind a reverse proxy](#expose-on-a-domain-https).

### Flags

| Flag | Meaning | Default |
|-------------------|-----------------------------------------------------|---------|
| `--server-only` | Run headless as a web server (no desktop GUI) | — |
| `--port <n>` | TCP port to listen on | `8787` |
| `--lan` | Bind `0.0.0.0` (reachable from the LAN) | off (localhost only) |

| Env var | Meaning |
|-------------------------------|--------------------------------------------------|
| `OPENIPC_WEB_ADMIN_PASSWORD` | Built-in admin password. Unset → random, logged on start. |

---

## Adding cameras

Four ways, all landing in the same database:

* **Find them on the network** — *Discovery → Scan*. Passive sources (ONVIF
WS-Discovery, mDNS) always run; *Deep scan* additionally sweeps the local
subnet, which finds cameras that announce nothing but looks like a port scan
to anyone watching the network. Pick a device, enter its login, *Probe* to read
the real RTSP URL and capabilities off the camera, then *Add*.
* **In the web UI** — *Cameras → + Add camera*.
* **Import a backup** — *System → Import* a JSON file exported from the desktop app
or another instance (*System → Export config* produces one; camera passwords are
never included in the export).
* **Reuse the desktop app's data** — run the server on a machine that already has
the desktop app configured; it uses the same on-disk database:

| OS | Database path |
|---------|----------------------------------------------------------------------|
| Windows | `%LOCALAPPDATA%\OpenIPC.Viewer\openipc-viewer.db` |
| Linux | `~/.local/share/openipc-viewer/openipc-viewer.db` (`$XDG_DATA_HOME`) |
| macOS | `~/Library/Application Support/OpenIPC.Viewer/openipc-viewer.db` |

Camera credentials live in the OS secret store, not in this file.

---

## Users and permissions

Out of the box there is one account: the built-in administrator from
`OPENIPC_WEB_ADMIN_PASSWORD`. *Users → + Add user* creates more, each with:

| Permission | Grants |
|----------------|---------------------------------------------------------------|
| `watch live` | see the camera list and live video |
| `PTZ` | move the camera and manage its presets |
| `export` | reserved for archive export |
| `manage` | everything that changes the installation — cameras, groups, layouts, discovery, backups, sessions, users |

and, optionally, a **subset of cameras**: leave *All cameras* on for full access,
or tick the ones a user may see. Cameras outside the subset are invisible —
absent from the list, from the grid, and from the API.

The roster lives next to the database as `web-users.json` (passwords are stored
as PBKDF2 hashes, never in the clear). The built-in administrator keeps working
even after you add users — it is the way back in if the last `manage` account is
lost, so keep its password safe.

> Permission changes take effect the next time that user signs in.

---

## ffmpeg

Live video needs ffmpeg. Resolution order: a bundled binary next to the app
(`runtimes/<rid>/native/ffmpeg`) first, then `ffmpeg` on `PATH`.

* **Windows** — bundled in the release archive. Nothing to install.
* **Linux** — bundled in the release archive; or `sudo apt install ffmpeg`.
* **macOS** — install via `brew install ffmpeg` (not bundled).

---

## Expose on a domain (HTTPS)

The server speaks plain HTTP. To reach it over the internet or from an untrusted
network, terminate TLS at a reverse proxy and forward to the local port. The server
already honours `X-Forwarded-*` from a loopback proxy, and the session cookie
becomes `Secure` automatically once requests arrive over HTTPS.

**Caddy** (automatic Let's Encrypt certificates, WebSockets pass through as-is):

```caddy
cameras.example.com {
reverse_proxy 127.0.0.1:8787
}
```

Run the app **without** `--lan` (localhost bind) when the proxy is on the same host
— nothing else needs network exposure. If the proxy runs on a *different* host,
bind with `--lan` and pass the proxy's IP so its forwarded headers are trusted
(otherwise cross-origin/CSRF checks reject proxied requests):

```bash
./OpenIPC.Viewer.Desktop --server-only --lan --port 8787
# and set the trusted proxy IP(s) — see WebServerOptions.TrustedProxies
```

**nginx** equivalent needs the WebSocket upgrade headers:

```nginx
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # live-video WebSocket
proxy_set_header Connection "upgrade";
}
```

---

## Run it as a service

**Linux (systemd)** — `/etc/systemd/system/openipc-web.service`:

```ini
[Unit]
Description=OpenIPC Viewer web server
After=network.target

[Service]
Environment=OPENIPC_WEB_ADMIN_PASSWORD=choose-a-strong-one
ExecStart=/opt/openipc-viewer/OpenIPC.Viewer.Desktop --server-only --lan --port 8787
Restart=on-failure
User=openipc

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl enable --now openipc-web
```

**Windows** — run at logon via Task Scheduler, or wrap it as a service with
[NSSM](https://nssm.cc/). Set `OPENIPC_WEB_ADMIN_PASSWORD` as a machine environment
variable so the service picks it up.

---

## Security notes

* **Localhost by default.** Network exposure (`--lan`) is opt-in and warned about.
* **No built-in TLS** — use a reverse proxy for HTTPS (above).
* **Accounts and permissions.** Login is rate-limited (5/min/IP); sessions are
opaque tokens in an `HttpOnly`, `SameSite=Strict` cookie with a 12-hour sliding
expiry. Every request is authorized on the server — the UI hides what you may
not do, but hiding is not the boundary.
* **The web server is a separate door to your cameras.** Anyone who reaches the
port with an account gets whatever their permissions allow, from any machine.
Bind to the LAN deliberately, and give each person only the cameras they need.
* **Config export** never contains camera passwords.
* Health check: `GET /healthz` (no auth) returns `{"status":"ok","version":"…"}`.

---

## Build from source

```bash
dotnet publish src/OpenIPC.Viewer.Desktop -c Release -r linux-x64 \
--self-contained true -o publish
# Node is required — the build compiles the React web UI and embeds it.
./publish/OpenIPC.Viewer.Desktop --server-only --lan --port 8787
```

Swap `linux-x64` for `win-x64` / `osx-arm64` as needed.

---

## How it's built — where does the React app go?

There is **no separate front-end to deploy**. The browser UI is a React + Vite
single-page app whose *compiled output is embedded inside the server binary* at
build time, so at runtime there is no Node, no `dist/` to copy, and no static-file
server to run — just the one executable.

```
src/OpenIPC.Viewer.Web.Client React + TypeScript + Vite (source)
│ dotnet build / publish runs an MSBuild target →
▼ npm run build (Vite emits index.html + hashed assets)
OpenIPC.Viewer.Web/wwwroot build output
│ embedded as resources into the assembly →
OpenIPC.Viewer.Web.dll the compiled SPA lives *inside* this DLL
│ at runtime Kestrel serves it from memory →
GET / → embedded index.html
GET /assets/* → embedded JS / CSS
GET /grid, … → SPA client routes (fallback to index.html)
```

Node is therefore a **build-time-only** dependency (CI installs it; a source build
needs it). A downloaded release archive already has the React app baked into the
binary. This embed-in-the-DLL approach also means the web UI travels with the
server assembly no matter which host loads it, rather than depending on a
framework-specific static-assets pipeline.
Loading
Loading