diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6015efa0..89ba42ca 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- - 'master'
+ - 'beta'
tags:
- 'v*'
@@ -19,7 +19,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
- with: { go-version: '1.25' }
+ with: { go-version: '1.26' }
- name: Build go2rtc_win64
env: { GOOS: windows, GOARCH: amd64 }
@@ -123,9 +123,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
- images: |
- name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
- ghcr.io/${{ github.repository }}
+ images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}},enable=false
@@ -137,15 +135,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Login to DockerHub
- if: github.event_name == 'push' && github.event.repository.fork == false
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Login to GitHub Container Registry
- if: github.event_name == 'push'
+ if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -180,9 +171,7 @@ jobs:
id: meta-hw
uses: docker/metadata-action@v5
with:
- images: |
- name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
- ghcr.io/${{ github.repository }}
+ images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-hardware,onlatest=true
latest=auto
@@ -197,15 +186,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Login to DockerHub
- if: github.event_name == 'push' && github.event.repository.fork == false
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Login to GitHub Container Registry
- if: github.event_name == 'push'
+ if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -235,9 +217,7 @@ jobs:
id: meta-rk
uses: docker/metadata-action@v5
with:
- images: |
- name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
- ghcr.io/${{ github.repository }}
+ images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-rockchip,onlatest=true
latest=auto
@@ -252,15 +232,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Login to DockerHub
- if: github.event_name == 'push' && github.event.repository.fork == false
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Login to GitHub Container Registry
- if: github.event_name == 'push'
+ if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5d9e7e25..b81245f4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -26,7 +26,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
- go-version: '1.24'
+ go-version: '1.26'
- name: Build Go binary
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
diff --git a/.gitignore b/.gitignore
index 5d539075..201329c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,4 +21,9 @@ website/.vitepress/dist
node_modules
package-lock.json
-CLAUDE.md
\ No newline at end of file
+CLAUDE.md
+*/**/CLAUDE.md
+.claude*
+.ruff*
+
+.omc
diff --git a/README.md b/README.md
index b15d57ab..6f7c1316 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,86 @@
Ultimate camera streaming application with support for dozens formats and protocols.
+---
+
+> ### 🔀 Fork: [skrashevich/go2rtc](https://github.com/skrashevich/go2rtc)
+>
+> This is a fork of [AlexxIT/go2rtc](https://github.com/AlexxIT/go2rtc) with the following additions:
+>
+> **Features**
+> - **HomeKit Secure Video (HKSV)** — full recording support with motion detection (P-frame analysis, ONVIF events, API)
+> - **ONVIF motion detection** — automatic motion events from ONVIF cameras for HomeKit
+> - **WebP streaming** — native WebP encoding (snapshot & multipart stream) without FFmpeg
+> - **System resource monitoring** — CPU/memory usage in API (`/api/system`) and WebUI ASCII graphs
+> - **Read-only mode** — disable all write operations in API/WebUI for production security
+> - **Offline WebUI in Docker** — CDN JS dependencies bundled into Docker images
+>
+> **WebUI improvements**
+> - **Redesigned interface** — dark/light theme toggle, unified color scheme, improved layout
+> - **Stream Info & Probe pages** — detailed stream analysis with producers/consumers data
+> - **Stream Links page** — direct URLs for all supported formats (RTSP, WebRTC, MSE, HLS, etc.)
+> - **Mobile-responsive** — improved header, tables, and word wrapping on mobile devices
+>
+> **Bug fixes & maintenance**
+> - **YAML config merge fix** — corrected recursive merge behavior preserving comments
+> - **Streams race condition fix** — fixed race condition in stream schema handling
+> - **Go 1.26** — updated to the latest Go runtime
+>
+> #### Download
+>
+> **Docker images** (GHCR):
+> ```bash
+> # Standard
+> docker pull ghcr.io/skrashevich/go2rtc:beta
+>
+> # With hardware acceleration (Intel/AMD)
+> docker pull ghcr.io/skrashevich/go2rtc:beta-hardware
+>
+> # Rockchip
+> docker pull ghcr.io/skrashevich/go2rtc:beta-rockchip
+> ```
+>
+> **Binaries**: download from [GitHub Actions](https://github.com/skrashevich/go2rtc/actions/workflows/build.yml) artifacts (select the latest successful run on the `beta` branch).
+> Available for: Windows (amd64, i386, arm64), Linux (amd64, i386, arm, arm64), macOS (amd64, arm64), FreeBSD (amd64, arm64).
+
+---
+
+## Screenshots
+
+| Streams Dashboard | Add Stream |
+|:-:|:-:|
+|  |  |
+| Stream list with status, actions, and system monitoring | Quick setup for dozens of protocols and integrations |
+
+| Stream Info | Stream Links |
+|:-:|:-:|
+|  |  |
+| Producers and consumers details | Direct URLs for all supported formats |
+
+| Config Editor | Logs |
+|:-:|:-:|
+|  |  |
+| YAML configuration with syntax highlighting | Real-time log viewer with auto-update |
+
+
+Light theme
+
+| Streams Dashboard | Add Stream |
+|:-:|:-:|
+|  |  |
+
+| Stream Info | Stream Links |
+|:-:|:-:|
+|  |  |
+
+| Config Editor | Logs |
+|:-:|:-:|
+|  |  |
+
+
+
+---
+
- zero-dependency [small app](#go2rtc-binary) for all OS (Windows, macOS, Linux, FreeBSD)
- zero-delay for many [supported protocols](#codecs-madness) (lowest possible streaming latency)
- [streaming input](#streaming-input) from dozens formats and protocols
@@ -135,7 +215,7 @@ It comes preinstalled with [FFmpeg](internal/ffmpeg/README.md) and [Python](inte
Latest, but maybe unstable version:
- Binary: [latest master build](https://nightly.link/AlexxIT/go2rtc/workflows/build/master)
-- Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions
+- Docker: `ghcr.io/skrashevich/go2rtc:beta` or `ghcr.io/skrashevich/go2rtc:beta-hardware` versions
- Home Assistant add-on: `go2rtc master` or `go2rtc master hardware` versions
## Configuration
@@ -144,7 +224,7 @@ This is the `go2rtc.yaml` file in [YAML-format](https://en.wikipedia.org/wiki/YA
The configuration can be changed in the [WebUI](www/README.md) at `http://localhost:1984`.
The editor provides syntax highlighting and checking.
-
+
The simplest config looks like this:
@@ -311,7 +391,7 @@ You can preload any stream on go2rtc start. This is useful for cameras that take
[WebUI](www/README.md) provides detailed information about all active connections, including IP-addresses, formats, protocols, number of packets and bytes transferred.
Via the [HTTP API](internal/api/README.md) in [`json`](https://en.wikipedia.org/wiki/JSON) or [`dot`](https://en.wikipedia.org/wiki/DOT_(graph_description_language)) format on an interactive connection map.
-
+
## Codecs
@@ -462,6 +542,8 @@ api:
allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg]
# enable auth for localhost (used together with username and password)
local_auth: true
+ # disable write actions in WebUI/API
+ read_only: true
exec:
# use only allowed exec paths
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 9efded4b..1afb774b 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -2,7 +2,7 @@
# 0. Prepare images
ARG PYTHON_VERSION="3.13"
-ARG GO_VERSION="1.25"
+ARG GO_VERSION="1.26"
# 1. Build go2rtc binary
@@ -26,7 +26,15 @@ COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
-# 2. Final image
+# 2. Download CDN dependencies for offline web UI
+FROM alpine AS download-cdn
+RUN apk add --no-cache wget
+COPY www/ /web/
+COPY docker/download_cdn.sh /tmp/
+RUN sh /tmp/download_cdn.sh /web
+
+
+# 3. Final image
FROM python:${PYTHON_VERSION}-alpine AS base
# Install ffmpeg, tini (for signal handling),
@@ -46,10 +54,11 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver
# RUN libva-vdpau-driver mesa-vdpau-gallium (+150MB total)
COPY --from=build /build/go2rtc /usr/local/bin/
+COPY --from=download-cdn /web /var/www/go2rtc
+COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/
EXPOSE 1984 8554 8555 8555/udp
-ENTRYPOINT ["/sbin/tini", "--"]
+ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
VOLUME /config
WORKDIR /config
-CMD ["go2rtc", "-config", "/config/go2rtc.yaml"]
diff --git a/docker/README.md b/docker/README.md
index 41069baf..5537eba8 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -7,9 +7,9 @@ Images are built automatically via [GitHub actions](https://github.com/AlexxIT/g
- `alexxit/go2rtc:latest` - latest release based on `alpine` (`amd64`, `386`, `arm/v6`, `arm/v7`, `arm64`) with support for hardware transcoding for Intel iGPU and Raspberry
- `alexxit/go2rtc:latest-hardware` - latest release based on `debian 13` (`amd64`) with support for hardware transcoding for Intel iGPU, AMD GPU and NVidia GPU
- `alexxit/go2rtc:latest-rockchip` - latest release based on `debian 12` (`arm64`) with support for hardware transcoding for Rockchip RK35xx
-- `alexxit/go2rtc:master` - latest unstable version based on `alpine`
-- `alexxit/go2rtc:master-hardware` - latest unstable version based on `debian 13` (`amd64`)
-- `alexxit/go2rtc:master-rockchip` - latest unstable version based on `debian 12` (`arm64`)
+- `ghcr.io/skrashevich/go2rtc:beta` - latest unstable version based on `alpine`
+- `ghcr.io/skrashevich/go2rtc:beta-hardware` - latest unstable version based on `debian 13` (`amd64`)
+- `ghcr.io/skrashevich/go2rtc:beta-rockchip` - latest unstable version based on `debian 12` (`arm64`)
## Docker compose
diff --git a/docker/cdn_test.go b/docker/cdn_test.go
new file mode 100644
index 00000000..cbcec59b
--- /dev/null
+++ b/docker/cdn_test.go
@@ -0,0 +1,407 @@
+package docker_test
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// cdnURLPattern is the same regex used by download_cdn.sh to extract CDN URLs.
+var cdnURLPattern = regexp.MustCompile(`https://cdn\.jsdelivr\.net/npm/[^"' )\x60]*`)
+
+// HTML fixtures that mirror the real www/*.html files.
+var htmlFixtures = map[string]string{
+ "hls.html": `
+
+
+
+
+
+`,
+
+ "config.html": `
+
+
+
+
+
+
+`,
+
+ "net.html": `
+
+
+
+
+
+`,
+
+ "links.html": `
+
+
+
+
+`,
+}
+
+func parseCDNURL(rawURL string) (pkgName, filePath, localURL string) {
+ npmPath := strings.TrimPrefix(rawURL, "https://cdn.jsdelivr.net/npm/")
+
+ parts := strings.SplitN(npmPath, "/", 2)
+ pkgVer := parts[0]
+
+ if len(parts) > 1 {
+ filePath = parts[1]
+ }
+
+ // Remove @version suffix to get package name
+ if idx := strings.LastIndex(pkgVer, "@"); idx > 0 {
+ pkgName = pkgVer[:idx]
+ } else {
+ pkgName = pkgVer
+ }
+
+ if filePath != "" {
+ localURL = "cdn/" + pkgName + "/" + filePath
+ } else {
+ localURL = "cdn/" + pkgName + "/index.js"
+ }
+ return
+}
+
+// extractURLs finds all CDN URLs in the given HTML content.
+func extractURLs(htmlFiles map[string]string) []string {
+ seen := map[string]bool{}
+ for _, content := range htmlFiles {
+ for _, match := range cdnURLPattern.FindAllString(content, -1) {
+ seen[match] = true
+ }
+ }
+ urls := make([]string, 0, len(seen))
+ for u := range seen {
+ urls = append(urls, u)
+ }
+ sort.Strings(urls)
+ return urls
+}
+
+// patchHTML replaces all CDN URLs in content with local paths.
+func patchHTML(content string, urls []string) string {
+ for _, u := range urls {
+ _, _, localURL := parseCDNURL(u)
+ content = strings.ReplaceAll(content, u, localURL)
+ }
+ return content
+}
+
+func TestExtractURLs(t *testing.T) {
+ urls := extractURLs(htmlFixtures)
+
+ expected := []string{
+ "https://cdn.jsdelivr.net/npm/hls.js@1",
+ "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
+ "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min",
+ "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js",
+ "https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js",
+ "https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js",
+ }
+
+ if len(urls) != len(expected) {
+ t.Fatalf("expected %d URLs, got %d: %v", len(expected), len(urls), urls)
+ }
+ for i, u := range urls {
+ if u != expected[i] {
+ t.Errorf("URL[%d]: expected %q, got %q", i, expected[i], u)
+ }
+ }
+}
+
+func TestParseCDNURL(t *testing.T) {
+ tests := []struct {
+ url string
+ pkgName string
+ filePath string
+ localURL string
+ }{
+ {
+ url: "https://cdn.jsdelivr.net/npm/hls.js@1",
+ pkgName: "hls.js",
+ filePath: "",
+ localURL: "cdn/hls.js/index.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
+ pkgName: "js-yaml",
+ filePath: "dist/js-yaml.min.js",
+ localURL: "cdn/js-yaml/dist/js-yaml.min.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js",
+ pkgName: "monaco-editor",
+ filePath: "min/vs/loader.js",
+ localURL: "cdn/monaco-editor/min/vs/loader.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min",
+ pkgName: "monaco-editor",
+ filePath: "min",
+ localURL: "cdn/monaco-editor/min",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js",
+ pkgName: "vis-network",
+ filePath: "standalone/umd/vis-network.min.js",
+ localURL: "cdn/vis-network/standalone/umd/vis-network.min.js",
+ },
+ {
+ url: "https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js",
+ pkgName: "qrcodejs",
+ filePath: "qrcode.min.js",
+ localURL: "cdn/qrcodejs/qrcode.min.js",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.pkgName, func(t *testing.T) {
+ pkgName, filePath, localURL := parseCDNURL(tt.url)
+ if pkgName != tt.pkgName {
+ t.Errorf("pkgName: expected %q, got %q", tt.pkgName, pkgName)
+ }
+ if filePath != tt.filePath {
+ t.Errorf("filePath: expected %q, got %q", tt.filePath, filePath)
+ }
+ if localURL != tt.localURL {
+ t.Errorf("localURL: expected %q, got %q", tt.localURL, localURL)
+ }
+ })
+ }
+}
+
+func TestPatchHTML(t *testing.T) {
+ urls := extractURLs(htmlFixtures)
+
+ t.Run("hls", func(t *testing.T) {
+ patched := patchHTML(htmlFixtures["hls.html"], urls)
+ if !strings.Contains(patched, `src="cdn/hls.js/index.js"`) {
+ t.Error("hls.js src not patched")
+ }
+ if strings.Contains(patched, "cdn.jsdelivr.net") {
+ t.Error("CDN URL still present")
+ }
+ if !strings.Contains(patched, `