Compare commits

...

12 Commits

Author SHA1 Message Date
Miroslav Šedivý 22e35dd525 server: reject cross-origin WebSocket handshakes when CORS is empty.
With an empty server.cors, AllowOrigin accepted every Origin, so a hostile
page could open an authenticated WebSocket using the SameSite=None session
cookie. Now only same-origin, listed origins, or '*' are accepted.

Discovered by Yves Soete of Blacksight LLC.
2026-10-28 17:11:31 +01:00
Miroslav Šedivý 23d8a2718e oauth: ignore email when provider reports email_verified=false.
An unverified email could match admin_emails/user_emails on providers that
let users set their own email. Only an explicit false is rejected; providers
that omit the claim are still trusted. Document the behavior.

Reported-by: Zachariah Mithani <zachariah@aplosai.com> (AploSec)
2026-10-28 17:05:56 +01:00
Miroslav Šedivý 92f99168b8 chore: update Node.js version to 24 in webpage build workflow. 2026-10-20 11:00:33 +02:00
Miroslav Šedivý 97d737683f Update Go version to 1.26.
- Updated Docker images and base images in build scripts to use Go 1.26.
- Modified server Dockerfile to set the base image to Go 1.26.
- Updated go.mod files in the server and utils/docker directories to specify Go 1.26.
- Updated dependencies in server/go.mod to their latest versions compatible with Go 1.26.
- Updated documentation to reflect the requirement for Go 1.26.
2026-10-20 10:34:54 +02:00
Miroslav Šedivý 71216a30ca chore: golang.org/x/crypto to v0.52.0. 2026-10-20 10:32:23 +02:00
Miroslav Šedivý f399ac8df5 chore: update dependencies and devDependencies to latest versions
- Upgraded @docusaurus/core and @docusaurus/preset-classic from 3.7.0 to 3.10.2
- Upgraded docusaurus-plugin-openapi-docs and docusaurus-theme-openapi-docs from 4.3.4 to 5.2.0
- Upgraded @docusaurus/module-type-aliases, @docusaurus/tsconfig, and @docusaurus/types from 3.7.0 to 3.10.2
- Added overrides for js-yaml, postcss-selector-parser, serialize-javascript, tinypool, uuid, and yaml
2026-10-20 10:29:04 +02:00
Wai Cheng 020def0305 intel: fix VA-API encoders not registering (no element "vah264enc") (#721)
* intel: fix VA-API encoders not registering (no element "vah264enc")

The intel images fail to stream with a hardware encoder on stock config:
every client connect ends with '(pipeline error) no element "vah264enc"'.

There are three problems, all of which are needed to fix it:

1. The render group never reaches the neko process. supervisord resolves the
   supplementary groups of each program from /etc/group when it spawns it, but
   'rendergroup-init' (which calls usermod) is spawned in the same pass, so the
   neko process usually starts before the group has been assigned and cannot
   access /dev/dri/render*. Assign the group in an entrypoint, before
   supervisord starts, instead.

2. libva does not initialize the X11 display without an explicitly selected
   driver in this image, it fails with 'vaGetDriverNames() failed'. Set
   LIBVA_DRIVER_NAME=iHD, which is the only driver installed by the intel image.

3. GStreamer only registers the VA-API elements if a usable VA display is found
   while the plugins are scanned for the first time, and the outcome is cached
   in the user's registry. neko performs that scan at startup, before the X
   server is ready, so the cache ends up without vah264enc/vah264dec and stays
   that way. Wait for VA to be usable and drop the stale registry before
   starting neko.

* intel: keep the wrapper and the supervisord override in the intel image

Per the review on #721: no wrapper in every flavour with a runtime 'vainfo'
check, it should only be in the intel image.

- neko-serve moved to runtime/intel/neko-serve and is copied only by
  Dockerfile.intel. The 'command -v vainfo' check is dropped, only the intel
  image ships it.
- The [program:neko] block is overridden with runtime/intel/supervisord.neko.conf,
  copied to /etc/neko/supervisord/neko.conf. [include] is parsed after the main
  config and the last definition of a program wins, so supervisord.conf keeps
  'neko serve' and it does not have to be touched. Same mechanism as
  supervisord.nvidia.conf in the app images.
- runtime/supervisord.conf, runtime/Dockerfile and runtime/Dockerfile.nvidia are
  back to their previous content.
2026-10-18 15:01:59 +02:00
SamAcctX 8100f0f46b firefox, waterfox: let the neko user own the profile root (#718)
`mkdir -p /home/neko/.mozilla/firefox/profile.default/extensions` (and
the same for `.waterfox`) runs as root and only `profile.default` was
handed to the neko user, so the directories above it stayed root-owned.
The crash reporter of current Firefox (158 in the 3.2 images) keeps its
reports and Glean store in `.mozilla/firefox/Crash Reports`; after a
browser crash it cannot create that directory and exits with

  failed to acquire Glean store: Os { code: 13, kind: PermissionDenied, ... }
  (toolkit/crashreporter/client/app/src/send_ping.rs:51)

or, in auto-submit mode,

  Failed to create directory (/home/neko/.mozilla/firefox/Crash Reports/pending): Permission denied

so no report survives. Verified on firefox:3.2.0 by running the reporter
against a main-process minidump: it fails as shipped and creates
`Crash Reports/{glean,pending,events}` once `.mozilla` belongs to neko.
Waterfox builds its profile root the same way and gets the same fix.

The other apps are fine: the base image chowns all of /home/neko, the
Chromium-based ones and remmina only COPY --chown files into it,
tor-browser chowns its whole /opt install.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 22:02:06 +02:00
SamAcctX 5f40eb9388 docs: fix Apache websocket rewrite for /api/ws (#717)
The Apache example forwarded websocket upgrades with
`RewriteRule /ws(.*) "ws://localhost:8080/ws$1"`. The pattern is not
anchored, so `/api/ws` matches it too and is rewritten to `/ws`, the
legacy endpoint. The 3.2 client connects to `/api/ws?token=...`; through
this rule the legacy handler sees no username/password, logs in with
empty credentials and answers `system/disconnect` ("couldn't create a
new session: error response from backend: Unauthorized"), while the
server logs `invalid password` from the legacy proxy.

Forward every websocket upgrade to the same path instead. Verified with
httpd:2.4 in front of ghcr.io/m1k1o/neko/firefox:3.2.0: the old rule
reproduces the error, the new one connects the 3.2 client and still
serves legacy `/ws` clients.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 21:43:34 +02:00
Miroslav Šedivý 21ad64423f refactor: v3 config options take precedence over v2 deprecated. 2026-10-16 20:34:40 +02:00
Miroslav Šedivý 3191dbb058 Remove legacy mode.
- Deleted the legacy WebSocket client implementation (`wstoclient.go`) and its test file (`wstoclient_test.go`).
- Removed legacy handling from the HTTP manager, including legacy proxy setup and routing.
- Cleaned up session management by removing legacy host checks and related functions.
- Eliminated the legacy WebRTC handler and its associated logic.
- Updated session interface to remove legacy methods.
- Removed legacy API documentation and configuration options from migration guide and help files.
2026-10-16 20:23:56 +02:00
Miroslav Šedivý ff6dd395c9 docs: update release notes for v3.2.0 with new features, fixes, and changes. 2026-10-16 00:06:27 +02:00
55 changed files with 5223 additions and 9444 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 24
cache: npm
cache-dependency-path: webpage/package-lock.json
+1 -1
View File
@@ -40,7 +40,7 @@ RUN set -eux; apt-get update; \
#
# create a profile directory
mkdir -p /home/neko/.mozilla/firefox/profile.default/extensions; \
chown -R neko:neko /home/neko/.mozilla/firefox/profile.default; \
chown -R neko:neko /home/neko/.mozilla; \
#
# clean up
apt-get clean -y; \
+1 -1
View File
@@ -18,7 +18,7 @@ RUN set -eux; apt-get update; \
#
# create a profile directory
mkdir -p /home/neko/.mozilla/firefox/profile.default/extensions; \
chown -R neko:neko /home/neko/.mozilla/firefox/profile.default; \
chown -R neko:neko /home/neko/.mozilla; \
#
# clean up
apt-get --purge autoremove -y xz-utils; \
+1 -1
View File
@@ -17,7 +17,7 @@ RUN set -eux; apt-get update; \
#
# create a profile directory
mkdir -p /home/neko/.waterfox/profile.default/extensions; \
chown -R neko:neko /home/neko/.waterfox/profile.default; \
chown -R neko:neko /home/neko/.waterfox; \
#
# clean up
apt-get --purge autoremove -y xz-utils bzip2; \
+1 -1
View File
@@ -323,6 +323,6 @@ docker run --rm -i \
-e "RUNTIME_DOCKERFILE=$RUNTIME_DOCKERFILE" \
--workdir /src \
--entrypoint go \
golang:1.25-trixie \
golang:1.26-trixie \
run utils/docker/main.go \
-i Dockerfile.tmpl -client "$CLIENT_DIST" | build_image $BASE_IMAGE -f - .
+3 -3
View File
@@ -1,7 +1,7 @@
# neko client
The web client for [neko](https://github.com/m1k1o/neko): React 19 + TypeScript, built with Vite.
It speaks only the v3 API (`/api/ws` + REST), so it works with `NEKO_LEGACY=false`.
It speaks only the v3 API (`/api/ws` + REST).
```
client/
@@ -248,10 +248,10 @@ NEKO_URL=http://localhost:3000 npm run dev
`e2e.mjs` drives two (or more) headless Chromium users through login, video, chat, control, files,
admin actions, reconnects and URL parameters. It needs a disposable server (it takes control, types
into the desktop and kicks users) with the legacy API off and the multiuser provider:
into the desktop and kicks users) with the multiuser provider:
```sh
docker run -d -p 8080:8080 -p 52000:52000/udp -e NEKO_LEGACY=false \
docker run -d -p 8080:8080 -p 52000:52000/udp \
-e NEKO_MEMBER_PROVIDER=multiuser -e NEKO_MEMBER_MULTIUSER_USER_PASSWORD=neko \
-e NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=admin -e NEKO_FILETRANSFER_ENABLED=true \
-e NEKO_WEBRTC_UDPMUX=52000 \
+1 -1
View File
@@ -1,5 +1,5 @@
import { chromium } from 'playwright'
// Two-user smoke test against a running v3 server (NEKO_LEGACY=false, multiuser provider):
// Two-user smoke test against a running v3 server with the multiuser provider:
// NEKO_URL=https://host/ NEKO_ADMIN_PASSWORD=admin NEKO_USER_PASSWORD=neko npm run test:e2e
// Screenshots land in ./e2e-out. Needs a KDE/X desktop that opens KRunner on Alt+F2 for the typing step.
import fs from 'node:fs'
+8 -1
View File
@@ -82,12 +82,14 @@ RUN set -eux; \
# copy runtime configs
COPY --chown=neko:neko .Xresources /home/$USERNAME/.Xresources
COPY dbus /usr/bin/dbus
COPY intel/neko-serve /usr/bin/neko-serve
COPY default.pa /etc/pulse/default.pa
COPY supervisord.conf /etc/neko/supervisord.conf
COPY supervisord.dbus.conf /etc/neko/supervisord.dbus.conf
COPY intel/supervisord.neko.conf /etc/neko/supervisord/neko.conf
COPY xorg.conf /etc/neko/xorg.conf
COPY intel/add-render-group.sh /usr/bin/add-render-group.sh
COPY intel/supervisord.rendergroup.conf /etc/neko/supervisord/supervisord.rendergroup.conf
COPY intel/entrypoint.sh /usr/bin/entrypoint.sh
#
# copy runtime folders
@@ -106,6 +108,10 @@ ENV NEKO_PLUGINS_ENABLED=true
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
ENV NEKO_HWENC=VAAPI
ENV RENDER_GID=
# libva does not initialize the X11 display without an explicitly selected
# driver here ('vaGetDriverNames() failed'), and the GStreamer VA plugin scan
# uses X11. This image only ships the iHD driver (intel-media-va-driver-non-free).
ENV LIBVA_DRIVER_NAME=iHD
#
# add healthcheck
@@ -116,4 +122,5 @@ HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
#
# run neko
ENTRYPOINT ["/usr/bin/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/neko/supervisord.conf"]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# supervisord reads /etc/group to determine the supplementary groups of every
# program it spawns, so the render group has to be assigned before supervisord
# starts. Doing it from a program spawned by supervisord (as the previous
# 'rendergroup-init' did) races with the neko program: neko is usually spawned
# before 'usermod' has finished and is left without access to /dev/dri/render*,
# which in turn makes the VA-API encoders fail to register.
#
# Assign the render group to the neko user first, then hand over to supervisord.
if ! /usr/bin/add-render-group.sh; then
echo "entrypoint: could not assign the render group, VA-API may not work (is /dev/dri passed to the container?)" >&2
fi
exec "$@"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# GStreamer only registers the VA-API elements (e.g. vah264enc) if a usable VA
# display is found while the plugins are scanned for the first time, and the
# result of that scan is cached in the user's registry. supervisord starts the
# X server and neko in the same pass, so without waiting here neko can scan the
# plugins before X (and therefore VA-API) is usable. The cache then lacks the
# encoder elements and every client connect fails with
# '(pipeline error) no element "vah264enc"'.
#
# Wait until VA is usable through X, then drop any stale registry, so that the
# first scan of this process happens with a working VA display.
i=0
while [ "$i" -lt 30 ]; do
if vainfo >/dev/null 2>&1; then break; fi
sleep 1
i=$((i + 1))
done
if [ "$i" -ge 30 ]; then
echo "neko-serve: VA-API was not usable after 30s, starting neko anyway" >&2
fi
rm -f "$HOME/.cache/gstreamer-1.0/registry."*
exec /usr/bin/neko serve "$@"
+16
View File
@@ -0,0 +1,16 @@
# supervisord reads the files matched by [include] after the main config, and
# the last definition of a program wins, so this overrides the [program:neko]
# block from /etc/neko/supervisord.conf. Only the intel image ships
# /usr/bin/neko-serve. Keep the two blocks in sync.
[program:neko]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/neko-serve --server.static "/var/www"
stopsignal=INT
stopwaitsecs=3
autorestart=true
priority=800
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/neko.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
@@ -1,12 +0,0 @@
[program:rendergroup-init]
environment=RENDER_GID="%(ENV_RENDER_GID)s",USER="%(ENV_USER)s"
command=/usr/bin/add-render-group.sh
startsecs=0
startretries=0
autorestart=false
priority=10
user=root
stdout_logfile=/var/log/neko/rendergroup.log
stdout_logfile_maxbytes=1MB
stdout_logfile_backups=10
redirect_stderr=true
+1 -1
View File
@@ -1,4 +1,4 @@
ARG BASE_IMAGE=golang:1.25-trixie
ARG BASE_IMAGE=golang:1.26-trixie
FROM $BASE_IMAGE AS server
WORKDIR /src
+3 -9
View File
@@ -81,10 +81,7 @@ func init() {
// set root config values
rootConfig.Set()
// legacy if explicitly enabled or if unspecified and legacy config is found
if viper.GetBool("legacy") || !viper.IsSet("legacy") {
rootConfig.SetV2()
}
rootConfig.SetV2()
//////
// logs
@@ -166,11 +163,8 @@ func init() {
log.Panic().Err(err).Msg("unable to run root command")
}
// legacy if explicitly enabled or if unspecified and legacy config is found
if viper.GetBool("legacy") || !viper.IsSet("legacy") {
if err := rootConfig.InitV2(root); err != nil {
log.Panic().Err(err).Msg("unable to run root command")
}
if err := rootConfig.InitV2(root); err != nil {
log.Panic().Err(err).Msg("unable to run root command")
}
root.SetVersionTemplate(neko.Version.Details())
+23 -30
View File
@@ -7,7 +7,6 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/m1k1o/neko/server/internal/api"
"github.com/m1k1o/neko/server/internal/capture"
@@ -88,26 +87,23 @@ func (c *serve) Init(cmd *cobra.Command) error {
return err
}
// legacy if explicitly enabled or if unspecified and legacy config is found
if viper.GetBool("legacy") || !viper.IsSet("legacy") {
if err := c.configs.Desktop.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Capture.InitV2(cmd); err != nil {
return err
}
if err := c.configs.WebRTC.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Member.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Session.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Server.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Desktop.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Capture.InitV2(cmd); err != nil {
return err
}
if err := c.configs.WebRTC.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Member.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Session.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Server.InitV2(cmd); err != nil {
return err
}
return nil
@@ -124,15 +120,12 @@ func (c *serve) PreRun(cmd *cobra.Command, args []string) {
c.configs.Plugins.Set()
c.configs.Server.Set()
// legacy if explicitly enabled or if unspecified and legacy config is found
if viper.GetBool("legacy") || !viper.IsSet("legacy") {
c.configs.Desktop.SetV2()
c.configs.Capture.SetV2()
c.configs.WebRTC.SetV2()
c.configs.Member.SetV2()
c.configs.Session.SetV2()
c.configs.Server.SetV2()
}
c.configs.Desktop.SetV2()
c.configs.Capture.SetV2()
c.configs.WebRTC.SetV2()
c.configs.Member.SetV2()
c.configs.Session.SetV2()
c.configs.Server.SetV2()
}
func (c *serve) Start(cmd *cobra.Command) {
+33 -36
View File
@@ -1,22 +1,22 @@
module github.com/m1k1o/neko/server
go 1.25.0
go 1.26.0
require (
github.com/PaesslerAG/gval v1.2.4
github.com/fsnotify/fsnotify v1.9.0
github.com/go-chi/chi/v5 v5.2.5
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.5.0
github.com/go-chi/cors v1.2.2
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gorilla/websocket v1.5.3
github.com/kataras/go-events v0.0.3
github.com/pion/ice/v4 v4.2.2
github.com/pion/interceptor v0.1.44
github.com/pion/ice/v4 v4.4.8
github.com/pion/interceptor v0.1.49
github.com/pion/logging v0.2.4
github.com/pion/rtcp v1.2.16
github.com/pion/webrtc/v4 v4.2.11
github.com/prometheus/client_golang v1.23.2
github.com/rs/zerolog v1.35.0
github.com/pion/rtcp v1.2.19
github.com/pion/webrtc/v4 v4.2.24
github.com/prometheus/client_golang v1.25.0
github.com/rs/zerolog v1.35.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
)
@@ -24,41 +24,38 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pion/datachannel v1.6.3 // indirect
github.com/pion/dtls/v3 v3.1.15 // indirect
github.com/pion/mdns/v2 v2.2.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtp v1.10.1 // indirect
github.com/pion/sctp v1.9.4 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/pion/rtp v1.10.5 // indirect
github.com/pion/sctp v1.12.0 // indirect
github.com/pion/sdp/v3 v3.0.20 // indirect
github.com/pion/srtp/v3 v3.1.3 // indirect
github.com/pion/stun/v4 v4.0.1 // indirect
github.com/pion/transport/v5 v5.1.1 // indirect
github.com/pion/turn/v5 v5.1.2 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.72.1 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shopspring/decimal v1.6.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.10.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.16.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+70 -79
View File
@@ -7,14 +7,12 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg=
github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -29,76 +27,72 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kataras/go-events v0.0.3 h1:o5YK53uURXtrlg7qE/vovxd/yKOJcLuFtPQbf1rYMC4=
github.com/kataras/go-events v0.0.3/go.mod h1:bFBgtzwwzrag7kQmGuU1ZaVxhK2qseYPQomXoVEMsj4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.2 h1:dQJzzcgTFHDYyV3BoCfjPeX+JEtr58BWPi4PGyo6Vjg=
github.com/pion/ice/v4 v4.2.2/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/datachannel v1.6.3 h1:HZAYLpunVI0xi057ZiANl558d0uSpomcWw4UEPz3qXs=
github.com/pion/datachannel v1.6.3/go.mod h1:oRmkXVVXxkUbqa8MatgdNHW29kvtQY3HUdjJQsfv2GE=
github.com/pion/dtls/v3 v3.1.15 h1:okWk5LNwU21DfhvG8z4wm45knHVSPEdkihwp6BxfQcc=
github.com/pion/dtls/v3 v3.1.15/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc=
github.com/pion/ice/v4 v4.4.8 h1:NB22Ze5WrgMjcf9L+O+1B29bl6anq+sbyvddImDLdhc=
github.com/pion/ice/v4 v4.4.8/go.mod h1:zCRRg4Mkpdbx9B+75fd4z2APvIXeIraeUOB7IMPt8mU=
github.com/pion/interceptor v0.1.49 h1:iyBsNHRoLNNXZiJA/DdGvJgdyfS8YWDCzxIAlVjt5nY=
github.com/pion/interceptor v0.1.49/go.mod h1:MZ6PJkja/TCo350HAnBrs/rUIyad9mWjpcvytrf3ViQ=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/mdns/v2 v2.2.2 h1:qq9Kj3PnjF0mfSwd8Icu0CXy5aoYdQKnNUIsGPeUpgk=
github.com/pion/mdns/v2 v2.2.2/go.mod h1:ZX5f0AAH1D6TOjdjvcBORZZHaZuG0t9+br78lHEwiJ4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258=
github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU=
github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/pion/rtcp v1.2.19 h1:T79lR2Tfd/7zj+41nsUrftrUEee+Z03NQr9GSPSQ/WU=
github.com/pion/rtcp v1.2.19/go.mod h1:vX7Es6skWYm4oVvOSC5+4WHLYUTZHcbGNmk5wjzMGH4=
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.12.0 h1:AzUdVkVBNyGY1W4crWO1/aOWzMaW3zsVHGrNuAfFQiI=
github.com/pion/sctp v1.12.0/go.mod h1:6k1zrpkrxSwbisitn/AlzDQaa15DR49MFsrMochG/yc=
github.com/pion/sdp/v3 v3.0.20 h1:TS6DViqcmp+49f0+mjw9anbr9xY3vJtsZewxAvlMCRQ=
github.com/pion/sdp/v3 v3.0.20/go.mod h1:slIMXDK5OKj0nhISwjfeN18AzTBCt2LYZq9uPw0cU5Q=
github.com/pion/srtp/v3 v3.1.3 h1:O3sfbfWfq/OyEaXRbX02Z0SNQRijS9nDdc/DEb8jxlU=
github.com/pion/srtp/v3 v3.1.3/go.mod h1:v3uQtrCz9LH3aahau0jtzRDsWGwKkh4a0BiBwzzxhtQ=
github.com/pion/stun/v4 v4.0.1 h1:S2ggK4hsUJJKoZfpL90uIqIRTj6xJ0NOIEkXgcpQif0=
github.com/pion/stun/v4 v4.0.1/go.mod h1:byktbPA8U7HjJ2H8w3moP7nzq0Q+kYNulvMLPaubQL4=
github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8=
github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0=
github.com/pion/turn/v5 v5.1.2 h1:acEOO+D5txJ52vKfcilmSMItfP23Q9K6pJ1LM5P2VV0=
github.com/pion/turn/v5 v5.1.2/go.mod h1:57Oadc8fHnac15VFyCd6nEaBTVZBxQe2POUMD4lsHRA=
github.com/pion/webrtc/v4 v4.2.24 h1:Ove9nRpDAfK39+QSw3/Ru17VDiECjbiLaM73mRG4Yms=
github.com/pion/webrtc/v4 v4.2.24/go.mod h1:JxD/FgjMDKCWFukjknTdG5a9rR4PUxkg0yBX1/JBInM=
github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y=
github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -110,8 +104,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
@@ -120,22 +114,19 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-1
View File
@@ -86,7 +86,6 @@ func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
})
}
// TODO: Remove when legacy mode is removed as all sessions must be synced with their providers.
func (api *ApiManagerCtx) UpdateProfile(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
+84 -67
View File
@@ -378,14 +378,6 @@ func (s *Capture) Set() {
},
}
s.VideoIDs = []string{"main"}
if viper.GetBool("legacy") {
legacyPipeline := s.VideoPipelines["main"]
// Hacky way to enable pointer for legacy pipeline.
legacyPipeline.GstPipeline = strings.Replace(legacyPipeline.GstPipeline, "show-pointer=false", "show-pointer=true", 1)
s.VideoPipelines["legacy"] = legacyPipeline
// we do not add legacy to VideoIDs so that its ignored by bandwidth estimator
}
} else {
log.Warn().Msgf("no video pipelines specified, using default")
@@ -414,13 +406,6 @@ func (s *Capture) Set() {
},
}
s.VideoIDs = []string{"main"}
if viper.GetBool("legacy") {
legacyPipeline := s.VideoPipelines["main"]
legacyPipeline.ShowPointer = true
s.VideoPipelines["legacy"] = legacyPipeline
// we do not add legacy to VideoIDs so that its ignored by bandwidth estimator
}
}
} else if videoPipeline != "" {
log.Warn().Msg("you are setting both single video pipeline and multiple video pipelines, ignoring single video pipeline")
@@ -485,51 +470,63 @@ func (s *Capture) Set() {
}
func (s *Capture) SetV2() {
enableLegacy := false
var ok bool
deprecatedV2ConfigUsed := false
//
// video
//
if display := viper.GetString("display"); display != "" {
s.Display = display
if useV2Fallback("capture.video.display") {
s.Display = display
}
log.Warn().Msg("you are using v2 configuration 'NEKO_DISPLAY' which is deprecated, please use 'NEKO_CAPTURE_VIDEO_DISPLAY' and/or 'NEKO_DESKTOP_DISPLAY' instead, also consider using 'DISPLAY' env variable if both should be the same")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
modifiedVideoCodec := false
if videoCodec := viper.GetString("video_codec"); videoCodec != "" {
s.VideoCodec, ok = codec.ParseStr(videoCodec)
if !ok || s.VideoCodec.Type != webrtc.RTPCodecTypeVideo {
videoCodecValue, valid := codec.ParseStr(videoCodec)
if !valid || videoCodecValue.Type != webrtc.RTPCodecTypeVideo {
log.Warn().Str("codec", videoCodec).Msgf("unknown video codec, using Vp8")
s.VideoCodec = codec.VP8()
if useV2Fallback("capture.video.codec") {
s.VideoCodec = codec.VP8()
}
} else if useV2Fallback("capture.video.codec") {
s.VideoCodec = videoCodecValue
}
log.Warn().Msg("you are using v2 configuration 'NEKO_VIDEO_CODEC' which is deprecated, please use 'NEKO_CAPTURE_VIDEO_CODEC' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedVideoCodec = true
}
if viper.GetBool("vp8") {
s.VideoCodec = codec.VP8()
if useV2Fallback("capture.video.codec") {
s.VideoCodec = codec.VP8()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_VP8=true', use 'NEKO_CAPTURE_VIDEO_CODEC=vp8' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedVideoCodec = true
} else if viper.GetBool("vp9") {
s.VideoCodec = codec.VP9()
if useV2Fallback("capture.video.codec") {
s.VideoCodec = codec.VP9()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_VP9=true', use 'NEKO_CAPTURE_VIDEO_CODEC=vp9' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedVideoCodec = true
} else if viper.GetBool("h264") {
s.VideoCodec = codec.H264()
if useV2Fallback("capture.video.codec") {
s.VideoCodec = codec.H264()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_H264=true', use 'NEKO_CAPTURE_VIDEO_CODEC=h264' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedVideoCodec = true
} else if viper.GetBool("av1") {
s.VideoCodec = codec.AV1()
if useV2Fallback("capture.video.codec") {
s.VideoCodec = codec.AV1()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_AV1=true', use 'NEKO_CAPTURE_VIDEO_CODEC=av1' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedVideoCodec = true
}
@@ -566,12 +563,10 @@ func (s *Capture) SetV2() {
// Hacky way to disable pointer.
GstPipeline: strings.Replace(pipeline, "show-pointer=true", "show-pointer=false", 1),
},
"legacy": {
GstPipeline: pipeline,
},
}
// we do not add legacy to VideoIDs so that its ignored by bandwidth estimator
s.VideoIDs = []string{"main"}
if useV2Fallback("capture.video.ids") {
s.VideoIDs = []string{"main"}
}
}
}
@@ -580,7 +575,7 @@ func (s *Capture) SetV2() {
}
// TODO: add deprecated warning and proper alternative for HW enc, bitrate and max fps
enableLegacy = true
deprecatedV2ConfigUsed = true
}
//
@@ -588,42 +583,56 @@ func (s *Capture) SetV2() {
//
if audioDevice := viper.GetString("device"); audioDevice != "" {
s.AudioDevice = audioDevice
if useV2Fallback("capture.audio.device") {
s.AudioDevice = audioDevice
}
log.Warn().Msg("you are using v2 configuration 'NEKO_DEVICE' which is deprecated, please use 'NEKO_CAPTURE_AUDIO_DEVICE' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
modifiedAudioCodec := false
if audioCodec := viper.GetString("audio_codec"); audioCodec != "" {
s.AudioCodec, ok = codec.ParseStr(audioCodec)
if !ok || s.AudioCodec.Type != webrtc.RTPCodecTypeAudio {
audioCodecValue, valid := codec.ParseStr(audioCodec)
if !valid || audioCodecValue.Type != webrtc.RTPCodecTypeAudio {
log.Warn().Str("codec", audioCodec).Msgf("unknown audio codec, using Opus")
s.AudioCodec = codec.Opus()
if useV2Fallback("capture.audio.codec") {
s.AudioCodec = codec.Opus()
}
} else if useV2Fallback("capture.audio.codec") {
s.AudioCodec = audioCodecValue
}
log.Warn().Msg("you are using v2 configuration 'NEKO_AUDIO_CODEC' which is deprecated, please use 'NEKO_CAPTURE_AUDIO_CODEC' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedAudioCodec = true
}
if viper.GetBool("opus") {
s.AudioCodec = codec.Opus()
if useV2Fallback("capture.audio.codec") {
s.AudioCodec = codec.Opus()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_OPUS=true', use 'NEKO_CAPTURE_AUDIO_CODEC=opus' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedAudioCodec = true
} else if viper.GetBool("g722") {
s.AudioCodec = codec.G722()
if useV2Fallback("capture.audio.codec") {
s.AudioCodec = codec.G722()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_G722=true', use 'NEKO_CAPTURE_AUDIO_CODEC=g722' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedAudioCodec = true
} else if viper.GetBool("pcmu") {
s.AudioCodec = codec.PCMU()
if useV2Fallback("capture.audio.codec") {
s.AudioCodec = codec.PCMU()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_PCMU=true', use 'NEKO_CAPTURE_AUDIO_CODEC=pcmu' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedAudioCodec = true
} else if viper.GetBool("pcma") {
s.AudioCodec = codec.PCMA()
if useV2Fallback("capture.audio.codec") {
s.AudioCodec = codec.PCMA()
}
log.Warn().Msg("you are using deprecated config setting 'NEKO_PCMA=true', use 'NEKO_CAPTURE_AUDIO_CODEC=pcma' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
modifiedAudioCodec = true
}
@@ -632,11 +641,15 @@ func (s *Capture) SetV2() {
// audio pipeline
if modifiedAudioCodec || audioBitrate != 0 || audioPipeline != "" {
pipeline, err := NewAudioPipeline(s.AudioCodec, s.AudioDevice, audioPipeline, audioBitrate)
if err != nil {
log.Warn().Err(err).Msg("unable to create audio pipeline, using default")
if useV2Fallback("capture.audio.pipeline") {
pipeline, err := NewAudioPipeline(s.AudioCodec, s.AudioDevice, audioPipeline, audioBitrate)
if err != nil {
log.Warn().Err(err).Msg("unable to create audio pipeline, using default")
} else {
s.AudioPipeline = pipeline
}
} else {
s.AudioPipeline = pipeline
log.Warn().Msg("ignoring legacy audio pipeline settings because NEKO_CAPTURE_AUDIO_PIPELINE is already set")
}
if audioPipeline != "" {
@@ -644,7 +657,7 @@ func (s *Capture) SetV2() {
}
// TODO: add deprecated warning and proper alternative for audio bitrate
enableLegacy = true
deprecatedV2ConfigUsed = true
}
//
@@ -652,24 +665,28 @@ func (s *Capture) SetV2() {
//
if viper.IsSet("broadcast_pipeline") {
s.BroadcastPipeline = viper.GetString("broadcast_pipeline")
if useV2Fallback("capture.broadcast.pipeline") {
s.BroadcastPipeline = viper.GetString("broadcast_pipeline")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_BROADCAST_PIPELINE' which is deprecated, please use 'NEKO_CAPTURE_BROADCAST_PIPELINE' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("broadcast_url") {
s.BroadcastUrl = viper.GetString("broadcast_url")
if useV2Fallback("capture.broadcast.url") {
s.BroadcastUrl = viper.GetString("broadcast_url")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_BROADCAST_URL' which is deprecated, please use 'NEKO_CAPTURE_BROADCAST_URL' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("broadcast_autostart") {
s.BroadcastAutostart = viper.GetBool("broadcast_autostart")
if useV2Fallback("capture.broadcast.autostart") {
s.BroadcastAutostart = viper.GetBool("broadcast_autostart")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_BROADCAST_AUTOSTART' which is deprecated, please use 'NEKO_CAPTURE_BROADCAST_AUTOSTART' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
// set legacy flag if any V2 configuration was used
if !viper.IsSet("legacy") && enableLegacy {
log.Warn().Msg("legacy configuration is enabled because at least one V2 configuration was used, please migrate to V3 configuration, visit https://neko.m1k1o.net/docs/v3/migration-from-v2 for more details")
viper.Set("legacy", true)
if deprecatedV2ConfigUsed {
log.Error().Msg("deprecated V2 configuration is in use; migrate to V3 configuration, see https://neko.m1k1o.net/docs/v3/migration-from-v2")
}
}
+5 -7
View File
@@ -110,7 +110,7 @@ func (s *Desktop) Set() {
}
func (s *Desktop) SetV2() {
enableLegacy := false
deprecatedV2ConfigUsed := false
if viper.IsSet("screen") {
r := regexp.MustCompile(`([0-9]{1,4})x([0-9]{1,4})@([0-9]{1,3})`)
@@ -121,19 +121,17 @@ func (s *Desktop) SetV2() {
height, err2 := strconv.ParseInt(res[2], 10, 64)
rate, err3 := strconv.ParseInt(res[3], 10, 64)
if err1 == nil && err2 == nil && err3 == nil {
if err1 == nil && err2 == nil && err3 == nil && useV2Fallback("desktop.screen") {
s.ScreenSize.Width = int(width)
s.ScreenSize.Height = int(height)
s.ScreenSize.Rate = int16(rate)
}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_SCREEN' which is deprecated, please use 'NEKO_DESKTOP_SCREEN' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
// set legacy flag if any V2 configuration was used
if !viper.IsSet("legacy") && enableLegacy {
log.Warn().Msg("legacy configuration is enabled because at least one V2 configuration was used, please migrate to V3 configuration, visit https://neko.m1k1o.net/docs/v3/migration-from-v2 for more details")
viper.Set("legacy", true)
if deprecatedV2ConfigUsed {
log.Error().Msg("deprecated V2 configuration is in use; migrate to V3 configuration, see https://neko.m1k1o.net/docs/v3/migration-from-v2")
}
}
+19 -15
View File
@@ -266,27 +266,31 @@ func (s *Member) Set() {
}
func (s *Member) SetV2() {
enableLegacy := false
deprecatedV2ConfigUsed := false
if viper.IsSet("password") || viper.IsSet("password_admin") {
s.Provider = "multiuser"
if userPassword := viper.GetString("password"); userPassword != "" {
s.Multiuser.UserPassword = userPassword
} else {
s.Multiuser.UserPassword = "neko"
if useV2Fallback("member.provider") {
s.Provider = "multiuser"
}
if adminPassword := viper.GetString("password_admin"); adminPassword != "" {
s.Multiuser.AdminPassword = adminPassword
} else {
s.Multiuser.AdminPassword = "admin"
if viper.IsSet("password") && useV2Fallback("member.multiuser.user_password") {
if userPassword := viper.GetString("password"); userPassword != "" {
s.Multiuser.UserPassword = userPassword
} else {
s.Multiuser.UserPassword = "neko"
}
}
if viper.IsSet("password_admin") && useV2Fallback("member.multiuser.admin_password") {
if adminPassword := viper.GetString("password_admin"); adminPassword != "" {
s.Multiuser.AdminPassword = adminPassword
} else {
s.Multiuser.AdminPassword = "admin"
}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_PASSWORD' and 'NEKO_PASSWORD_ADMIN' which are deprecated, please use 'NEKO_MEMBER_MULTIUSER_USER_PASSWORD' and 'NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD' with 'NEKO_MEMBER_PROVIDER=multiuser' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
// set legacy flag if any V2 configuration was used
if !viper.IsSet("legacy") && enableLegacy {
log.Warn().Msg("legacy configuration is enabled because at least one V2 configuration was used, please migrate to V3 configuration, visit https://neko.m1k1o.net/docs/v3/migration-from-v2 for more details")
viper.Set("legacy", true)
if deprecatedV2ConfigUsed {
log.Error().Msg("deprecated V2 configuration is in use; migrate to V3 configuration, see https://neko.m1k1o.net/docs/v3/migration-from-v2")
}
}
+14 -29
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -11,9 +12,12 @@ import (
"github.com/spf13/viper"
)
func useV2Fallback(v3Keys ...string) bool {
return !slices.ContainsFunc(v3Keys, viper.IsSet)
}
type Root struct {
Config string
Legacy bool
LogLevel zerolog.Level
LogTime string
@@ -34,14 +38,6 @@ func (Root) Init(cmd *cobra.Command) error {
return err
}
// whether legacy configs and api should be enabled.
// - if not specified, it will be automatically enabled if at least one legacy config entry is found.
// - if it is specified, it will be enabled/disabled regardless of the presence of legacy config entries.
cmd.PersistentFlags().Bool("legacy", true, "enable legacy mode")
if err := viper.BindPFlag("legacy", cmd.PersistentFlags().Lookup("legacy")); err != nil {
return err
}
cmd.PersistentFlags().String("log.level", "info", "set log level (trace, debug, info, warn, error, fatal, panic, disabled)")
if err := viper.BindPFlag("log.level", cmd.PersistentFlags().Lookup("log.level")); err != nil {
return err
@@ -81,10 +77,6 @@ func (Root) InitV2(cmd *cobra.Command) error {
func (s *Root) Set() {
s.Config = viper.GetString("config")
s.Legacy = viper.GetBool("legacy")
if s.Legacy {
log.Info().Msg("legacy configuration is enabled")
}
logLevel := viper.GetString("log.level")
level, err := zerolog.ParseLevel(logLevel)
@@ -121,25 +113,18 @@ func (s *Root) Set() {
}
func (s *Root) SetV2() {
enableLegacy := false
if viper.IsSet("logs") {
if viper.GetBool("logs") {
logs := filepath.Join(".", "logs")
if runtime.GOOS == "linux" {
logs = "/var/log/neko"
if useV2Fallback("log.dir") {
if viper.GetBool("logs") {
logs := filepath.Join(".", "logs")
if runtime.GOOS == "linux" {
logs = "/var/log/neko"
}
s.LogDir = logs
} else {
s.LogDir = ""
}
s.LogDir = logs
} else {
s.LogDir = ""
}
log.Warn().Msg("you are using v2 configuration 'NEKO_LOGS' which is deprecated, please use 'NEKO_LOG_DIR=/path/to/logs' instead")
enableLegacy = true
}
// set legacy flag if any V2 configuration was used
if !viper.IsSet("legacy") && enableLegacy {
log.Warn().Msg("legacy configuration is enabled because at least one V2 configuration was used, please migrate to V3 configuration, visit https://neko.m1k1o.net/docs/v3/migration-from-v2 for more details")
viper.Set("legacy", true)
}
}
+34 -23
View File
@@ -62,7 +62,7 @@ func (Server) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().StringSlice("server.cors", []string{}, "list of allowed origins for CORS, if empty CORS is disabled, if '*' is present all origins are allowed")
cmd.PersistentFlags().StringSlice("server.cors", []string{}, "list of allowed origins for CORS and WebSocket connections, if empty only same-origin WebSocket connections are accepted, if '*' is present all origins are allowed")
if err := viper.BindPFlag("server.cors", cmd.PersistentFlags().Lookup("server.cors")); err != nil {
return err
}
@@ -127,52 +127,64 @@ func (s *Server) Set() {
}
func (s *Server) SetV2() {
enableLegacy := false
deprecatedV2ConfigUsed := false
if viper.IsSet("cert") {
s.Cert = viper.GetString("cert")
if useV2Fallback("server.cert") {
s.Cert = viper.GetString("cert")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_CERT' which is deprecated, please use 'NEKO_SERVER_CERT' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("key") {
s.Key = viper.GetString("key")
if useV2Fallback("server.key") {
s.Key = viper.GetString("key")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_KEY' which is deprecated, please use 'NEKO_SERVER_KEY' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("bind") {
s.Bind = viper.GetString("bind")
if useV2Fallback("server.bind") {
s.Bind = viper.GetString("bind")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_BIND' which is deprecated, please use 'NEKO_SERVER_BIND' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("proxy") {
s.Proxy = viper.GetBool("proxy")
if useV2Fallback("server.proxy") {
s.Proxy = viper.GetBool("proxy")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_PROXY' which is deprecated, please use 'NEKO_SERVER_PROXY' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("static") {
s.Static = viper.GetString("static")
if useV2Fallback("server.static") {
s.Static = viper.GetString("static")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_STATIC' which is deprecated, please use 'NEKO_SERVER_STATIC' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("path_prefix") {
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
if useV2Fallback("server.path_prefix") {
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
}
log.Warn().Msg("you are using v2 configuration 'NEKO_PATH_PREFIX' which is deprecated, please use 'NEKO_SERVER_PATH_PREFIX' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("cors") {
s.CORS = viper.GetStringSlice("cors")
if useV2Fallback("server.cors") {
s.CORS = viper.GetStringSlice("cors")
}
if slices.Contains(s.CORS, "*") {
s.CORS = []string{"*"}
log.Warn().Msg("CORS is enabled for all origins, this is not recommended for production environments")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_CORS' which is deprecated, please use 'NEKO_SERVER_CORS' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
// set legacy flag if any V2 configuration was used
if !viper.IsSet("legacy") && enableLegacy {
log.Warn().Msg("legacy configuration is enabled because at least one V2 configuration was used, please migrate to V3 configuration, visit https://neko.m1k1o.net/docs/v3/migration-from-v2 for more details")
viper.Set("legacy", true)
if deprecatedV2ConfigUsed {
log.Error().Msg("deprecated V2 configuration is in use; migrate to V3 configuration, see https://neko.m1k1o.net/docs/v3/migration-from-v2")
}
}
@@ -181,9 +193,8 @@ func (s *Server) HasCors() bool {
}
func (s *Server) AllowOrigin(origin string) bool {
// if CORS is not configured, or a wildcard is present,
// allow WebSocket connections from any origin
if len(s.CORS) == 0 || s.CORS[0] == "*" {
// only an explicit wildcard allows any origin
if len(s.CORS) > 0 && s.CORS[0] == "*" {
return true
}
+22 -14
View File
@@ -171,7 +171,7 @@ func (s *Session) Set() {
}
func (s *Session) SetV2() {
enableLegacy := false
deprecatedV2ConfigUsed := false
if viper.IsSet("locks") {
locks := viper.GetStringSlice("locks")
@@ -179,34 +179,42 @@ func (s *Session) SetV2() {
switch lock {
// TODO: file_transfer
case "control":
s.LockedControls = true
if useV2Fallback("session.locked_controls") {
s.LockedControls = true
}
case "login":
s.LockedLogins = true
if useV2Fallback("session.locked_logins") {
s.LockedLogins = true
}
}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_LOCKS' which is deprecated, please use 'NEKO_SESSION_LOCKED_CONTROLS' and 'NEKO_SESSION_LOCKED_LOGINS' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("implicit_control") {
s.ImplicitHosting = viper.GetBool("implicit_control")
if useV2Fallback("session.implicit_hosting") {
s.ImplicitHosting = viper.GetBool("implicit_control")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_IMPLICIT_CONTROL' which is deprecated, please use 'NEKO_SESSION_IMPLICIT_HOSTING' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("control_protection") {
s.ControlProtection = viper.GetBool("control_protection")
if useV2Fallback("session.control_protection") {
s.ControlProtection = viper.GetBool("control_protection")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_CONTROL_PROTECTION' which is deprecated, please use 'NEKO_SESSION_CONTROL_PROTECTION' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("heartbeat_interval") {
s.HeartbeatInterval = viper.GetInt("heartbeat_interval")
if useV2Fallback("session.heartbeat_interval") {
s.HeartbeatInterval = viper.GetInt("heartbeat_interval")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_HEARTBEAT_INTERVAL' which is deprecated, please use 'NEKO_SESSION_HEARTBEAT_INTERVAL' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
// set legacy flag if any V2 configuration was used
if !viper.IsSet("legacy") && enableLegacy {
log.Warn().Msg("legacy configuration is enabled because at least one V2 configuration was used, please migrate to V3 configuration, visit https://neko.m1k1o.net/docs/v3/migration-from-v2 for more details")
viper.Set("legacy", true)
if deprecatedV2ConfigUsed {
log.Error().Msg("deprecated V2 configuration is in use; migrate to V3 configuration, see https://neko.m1k1o.net/docs/v3/migration-from-v2")
}
}
+45 -29
View File
@@ -318,27 +318,35 @@ func (s *WebRTC) Set() {
}
func (s *WebRTC) SetV2() {
enableLegacy := false
deprecatedV2ConfigUsed := false
if viper.IsSet("nat1to1") {
s.NAT1To1IPs = viper.GetStringSlice("nat1to1")
if useV2Fallback("webrtc.nat1to1") {
s.NAT1To1IPs = viper.GetStringSlice("nat1to1")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_NAT1TO1' which is deprecated, please use 'NEKO_WEBRTC_NAT1TO1' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("tcpmux") {
s.TCPMux = viper.GetInt("tcpmux")
if useV2Fallback("webrtc.tcpmux") {
s.TCPMux = viper.GetInt("tcpmux")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_TCPMUX' which is deprecated, please use 'NEKO_WEBRTC_TCPMUX' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("udpmux") {
s.UDPMux = viper.GetInt("udpmux")
if useV2Fallback("webrtc.udpmux") {
s.UDPMux = viper.GetInt("udpmux")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_UDPMUX' which is deprecated, please use 'NEKO_WEBRTC_UDPMUX' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("icelite") {
s.ICELite = viper.GetBool("icelite")
if useV2Fallback("webrtc.icelite") {
s.ICELite = viper.GetBool("icelite")
}
log.Warn().Msg("you are using v2 configuration 'NEKO_ICELITE' which is deprecated, please use 'NEKO_WEBRTC_ICELITE' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("iceservers") {
@@ -350,24 +358,32 @@ func (s *WebRTC) SetV2() {
log.Panic().Err(err).Msg("failed to process iceservers")
}
}
s.ICEServersFrontend = iceServers
s.ICEServersBackend = iceServers
if useV2Fallback("webrtc.iceservers.frontend", "webrtc.iceservers") {
s.ICEServersFrontend = iceServers
}
if useV2Fallback("webrtc.iceservers.backend", "webrtc.iceservers") {
s.ICEServersBackend = iceServers
}
log.Warn().Msg("you are using v2 configuration 'NEKO_ICESERVERS' which is deprecated, please use 'NEKO_WEBRTC_ICESERVERS_FRONTEND' and/or 'NEKO_WEBRTC_ICESERVERS_BACKEND' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("iceserver") {
iceServerSlice := viper.GetStringSlice("iceserver")
if len(iceServerSlice) > 0 {
s.ICEServersFrontend = append(s.ICEServersFrontend, types.ICEServer{URLs: iceServerSlice})
s.ICEServersBackend = append(s.ICEServersBackend, types.ICEServer{URLs: iceServerSlice})
if len(iceServerSlice) > 0 && (useV2Fallback("webrtc.iceservers.frontend", "webrtc.iceservers") || useV2Fallback("webrtc.iceservers.backend", "webrtc.iceservers")) {
if useV2Fallback("webrtc.iceservers.frontend", "webrtc.iceservers") {
s.ICEServersFrontend = append(s.ICEServersFrontend, types.ICEServer{URLs: iceServerSlice})
}
if useV2Fallback("webrtc.iceservers.backend", "webrtc.iceservers") {
s.ICEServersBackend = append(s.ICEServersBackend, types.ICEServer{URLs: iceServerSlice})
}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_ICESERVER' which is deprecated, please use 'NEKO_WEBRTC_ICESERVERS_FRONTEND' and/or 'NEKO_WEBRTC_ICESERVERS_BACKEND' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("ipfetch") {
if len(s.NAT1To1IPs) == 0 {
if len(s.NAT1To1IPs) == 0 && useV2Fallback("webrtc.nat1to1", "webrtc.ip_retrieval_url") {
ipfetch := viper.GetString("ipfetch")
ip, err := utils.HttpRequestGET(ipfetch)
if err != nil {
@@ -376,7 +392,7 @@ func (s *WebRTC) SetV2() {
s.NAT1To1IPs = append(s.NAT1To1IPs, ip)
}
log.Warn().Msg("you are using v2 configuration 'NEKO_IPFETCH' which is deprecated, please use 'NEKO_WEBRTC_IP_RETRIEVAL_URL' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
if viper.IsSet("epr") {
@@ -396,20 +412,20 @@ func (s *WebRTC) SetV2() {
}
}
if min > max {
s.EphemeralMin = max
s.EphemeralMax = min
} else {
s.EphemeralMin = min
s.EphemeralMax = max
if useV2Fallback("webrtc.epr") {
if min > max {
s.EphemeralMin = max
s.EphemeralMax = min
} else {
s.EphemeralMin = min
s.EphemeralMax = max
}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_EPR' which is deprecated, please use 'NEKO_WEBRTC_EPR' instead")
enableLegacy = true
deprecatedV2ConfigUsed = true
}
// set legacy flag if any V2 configuration was used
if !viper.IsSet("legacy") && enableLegacy {
log.Warn().Msg("legacy configuration is enabled because at least one V2 configuration was used, please migrate to V3 configuration, visit https://neko.m1k1o.net/docs/v3/migration-from-v2 for more details")
viper.Set("legacy", true)
if deprecatedV2ConfigUsed {
log.Error().Msg("deprecated V2 configuration is in use; migrate to V3 configuration, see https://neko.m1k1o.net/docs/v3/migration-from-v2")
}
}
@@ -1,68 +0,0 @@
package event
const (
SYSTEM_INIT = "system/init"
SYSTEM_DISCONNECT = "system/disconnect"
SYSTEM_ERROR = "system/error"
)
const (
CLIENT_HEARTBEAT = "client/heartbeat"
)
const (
SIGNAL_OFFER = "signal/offer"
SIGNAL_ANSWER = "signal/answer"
SIGNAL_PROVIDE = "signal/provide"
SIGNAL_CANDIDATE = "signal/candidate"
)
const (
MEMBER_LIST = "member/list"
MEMBER_CONNECTED = "member/connected"
MEMBER_DISCONNECTED = "member/disconnected"
)
const (
CONTROL_LOCKED = "control/locked"
CONTROL_RELEASE = "control/release"
CONTROL_REQUEST = "control/request"
CONTROL_REQUESTING = "control/requesting"
CONTROL_GIVE = "control/give"
CONTROL_CLIPBOARD = "control/clipboard"
CONTROL_KEYBOARD = "control/keyboard"
)
const (
CHAT_MESSAGE = "chat/message"
CHAT_EMOTE = "chat/emote"
)
const (
FILETRANSFER_LIST = "filetransfer/list"
FILETRANSFER_REFRESH = "filetransfer/refresh"
)
const (
SCREEN_CONFIGURATIONS = "screen/configurations"
SCREEN_RESOLUTION = "screen/resolution"
SCREEN_SET = "screen/set"
)
const (
BROADCAST_STATUS = "broadcast/status"
BROADCAST_CREATE = "broadcast/create"
BROADCAST_DESTROY = "broadcast/destroy"
)
const (
ADMIN_BAN = "admin/ban"
ADMIN_KICK = "admin/kick"
ADMIN_LOCK = "admin/lock"
ADMIN_MUTE = "admin/mute"
ADMIN_UNLOCK = "admin/unlock"
ADMIN_UNMUTE = "admin/unmute"
ADMIN_CONTROL = "admin/control"
ADMIN_RELEASE = "admin/release"
ADMIN_GIVE = "admin/give"
)
-463
View File
@@ -1,463 +0,0 @@
package legacy
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/m1k1o/neko/server/internal/api"
oldEvent "github.com/m1k1o/neko/server/internal/http/legacy/event"
oldMessage "github.com/m1k1o/neko/server/internal/http/legacy/message"
oldTypes "github.com/m1k1o/neko/server/internal/http/legacy/types"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/types/event"
"github.com/m1k1o/neko/server/pkg/types/message"
"github.com/m1k1o/neko/server/pkg/utils"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var (
// DefaultUpgrader specifies the parameters for upgrading an HTTP
// connection to a WebSocket connection.
DefaultUpgrader = &websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
)
type LegacyHandler struct {
logger zerolog.Logger
serverAddr string
pathPrefix string
bannedIPs map[string]struct{}
sessionIPs map[string]string
wsDialer *websocket.Dialer
}
func New(serverAddr, pathPrefix string) *LegacyHandler {
// Init
return &LegacyHandler{
logger: log.With().Str("module", "legacy").Logger(),
serverAddr: serverAddr,
pathPrefix: pathPrefix,
bannedIPs: make(map[string]struct{}),
sessionIPs: make(map[string]string),
wsDialer: &websocket.Dialer{
Proxy: nil, // disable proxy for local requests
HandshakeTimeout: 45 * time.Second,
},
}
}
func (h *LegacyHandler) Route(r types.Router) {
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) error {
s := h.newSession(r)
// create a new websocket connection
connClient, err := DefaultUpgrader.Upgrade(w, r, nil)
if err != nil {
return utils.HttpError(http.StatusInternalServerError).
WithInternalErr(err).
Msg("couldn't upgrade connection to websocket")
}
defer connClient.Close()
s.connClient = connClient
if h.isBanned(r) {
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Title: "banned ip",
Message: "you are banned",
})
}
// create a new session
username := r.URL.Query().Get("username")
password := r.URL.Query().Get("password")
err = s.create(username, password)
if err != nil {
h.logger.Error().Err(err).Msg("couldn't create a new session")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Title: "couldn't create a new session",
Message: err.Error(),
})
// we can't return HTTP error here because the connection is already upgraded
return nil
}
defer s.destroy()
// dial to the remote backend
backendURL := "ws://" + h.serverAddr + path.Join(s.pathPrefix, "/api/ws")
backendHeaders := make(http.Header)
if s.token != "" {
backendURL += "?token=" + url.QueryEscape(s.token)
} else {
backendHeaders.Set("Cookie", s.cookie)
}
connBackend, _, err := h.wsDialer.Dial(backendURL, backendHeaders)
if err != nil {
h.logger.Error().Err(err).Msg("couldn't dial to the remote backend")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Title: "couldn't dial to the remote backend",
Message: err.Error(),
})
// we can't return HTTP error here because the connection is already upgraded
return nil
}
defer connBackend.Close()
s.connBackend = connBackend
// request signal
videoAuto := true
if err = s.toBackend(event.SIGNAL_REQUEST, message.SignalRequest{
Video: types.PeerVideoRequest{
Auto: &videoAuto,
},
}); err != nil {
h.logger.Error().Err(err).Msg("couldn't request signal")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Title: "couldn't request signal",
Message: err.Error(),
})
// we can't return HTTP error here because the connection is already upgraded
return nil
}
// copy messages between the client and the backend
errClient := make(chan error, 1)
errBackend := make(chan error, 1)
replicateWebsocketConn := func(dst, src *websocket.Conn, errc chan error, rewriteTextMessage func([]byte) error) {
for {
msgType, msg, err := src.ReadMessage()
if err != nil {
m := websocket.FormatCloseMessage(websocket.CloseNormalClosure, fmt.Sprintf("%v", err))
if e, ok := err.(*websocket.CloseError); ok {
if e.Code != websocket.CloseNoStatusReceived {
m = websocket.FormatCloseMessage(e.Code, e.Text)
}
}
errc <- fmt.Errorf("src read message error: %w", err)
dst.WriteMessage(websocket.CloseMessage, m)
break
}
// handle text messages
if msgType == websocket.TextMessage {
err = rewriteTextMessage(msg)
if err == nil {
continue
}
if errors.Is(err, ErrBackendRespone) {
h.logger.Error().Err(err).Msg("backend response error")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_ERROR,
Title: "backend response error",
Message: strings.ReplaceAll(err.Error(), ErrBackendRespone.Error()+": ", ""),
})
continue
}
if errors.Is(err, ErrWebsocketSend) {
errc <- fmt.Errorf("dst write message error: %w", err)
break
}
h.logger.Error().Err(err).Msg("couldn't rewrite text message")
continue
}
// forward ping pong messages
if msgType == websocket.PingMessage ||
msgType == websocket.PongMessage {
err = dst.WriteMessage(msgType, msg)
if err != nil {
errc <- err
break
}
continue
}
}
}
// backend -> client
go replicateWebsocketConn(connClient, connBackend, errClient, s.wsToClient)
// client -> backend
go replicateWebsocketConn(connBackend, connClient, errBackend, s.wsToBackend)
var message string
select {
case err = <-errClient:
message = "websocketproxy: Error when copying from backend to client"
case err = <-errBackend:
message = "websocketproxy: Error when copying from client to backend"
}
if e, ok := err.(*websocket.CloseError); !ok || e.Code == websocket.CloseAbnormalClosure {
h.logger.Error().Err(err).Msg(message)
}
return nil
})
r.Get("/stats", func(w http.ResponseWriter, r *http.Request) error {
if h.isBanned(r) {
return utils.HttpForbidden("banned ip")
}
s := h.newSession(r)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
if !s.isAdmin {
return utils.HttpUnauthorized().Msg("bad authorization")
}
w.Header().Set("Content-Type", "application/json")
// get all sessions
sessions := []api.SessionDataPayload{}
err = s.apiReq(http.MethodGet, "/api/sessions", nil, &sessions)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// get stats
newStats := types.Stats{}
err = s.apiReq(http.MethodGet, "/api/stats", nil, &newStats)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// get settings
settings := types.Settings{}
err = s.apiReq(http.MethodGet, "/api/room/settings", nil, &settings)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
var stats oldTypes.Stats
// create empty array so that it's not null in json
stats.Members = []*oldTypes.Member{}
for _, session := range sessions {
if session.State.IsConnected {
stats.Connections++
member, err := profileToMember(session.ID, session.Profile)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// append members
stats.Members = append(stats.Members, member)
}
}
locks, err := s.settingsToLocks(settings)
if err != nil {
return err
}
stats.Host = newStats.HostId
// TODO: stats.Banned, not implemented yet
stats.Locked = locks
stats.ServerStartedAt = newStats.ServerStartedAt
stats.LastAdminLeftAt = newStats.LastAdminLeftAt
stats.LastUserLeftAt = newStats.LastUserLeftAt
stats.ControlProtection = settings.ControlProtection
stats.ImplicitControl = settings.ImplicitHosting
return json.NewEncoder(w).Encode(stats)
})
r.Get("/screenshot.jpg", func(w http.ResponseWriter, r *http.Request) error {
if h.isBanned(r) {
return utils.HttpForbidden("banned ip")
}
s := h.newSession(r)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
if !s.isAdmin {
return utils.HttpUnauthorized().Msg("bad authorization")
}
quality := r.URL.Query().Get("quality")
// get the screenshot
body, headers, err := s.req(http.MethodGet, "/api/room/screen/shot.jpg?quality="+url.QueryEscape(quality), nil, nil)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// copy headers
w.Header().Set("Content-Length", headers.Get("Content-Length"))
w.Header().Set("Content-Type", headers.Get("Content-Type"))
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
// allow downloading and uploading files
r.Get("/file", func(w http.ResponseWriter, r *http.Request) error {
if h.isBanned(r) {
return utils.HttpForbidden("banned ip")
}
s := h.newSession(r)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
filename := r.URL.Query().Get("filename")
body, headers, err := s.req(http.MethodGet, "/api/filetransfer?filename="+url.QueryEscape(filename), r.Header, nil)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// copy headers
w.Header().Set("Content-Length", headers.Get("Content-Length"))
w.Header().Set("Content-Type", headers.Get("Content-Type"))
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
r.Post("/file", func(w http.ResponseWriter, r *http.Request) error {
if h.isBanned(r) {
return utils.HttpForbidden("banned ip")
}
s := h.newSession(r)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
body, _, err := s.req(http.MethodPost, "/api/filetransfer", r.Header, r.Body)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
r.Delete("/file", func(w http.ResponseWriter, r *http.Request) error {
if h.isBanned(r) {
return utils.HttpForbidden("banned ip")
}
s := h.newSession(r)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
filename := r.URL.Query().Get("filename")
body, _, err := s.req(http.MethodDelete, "/api/filetransfer?filename="+url.QueryEscape(filename), r.Header, nil)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
r.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
_, err := w.Write([]byte("true"))
return err
})
}
func (h *LegacyHandler) ban(sessionId string) error {
// find session by id
ip, ok := h.sessionIPs[sessionId]
if !ok {
return fmt.Errorf("session not found")
}
h.bannedIPs[ip] = struct{}{}
return nil
}
func (h *LegacyHandler) isBanned(r *http.Request) bool {
ip := getIp(r)
_, ok := h.bannedIPs[ip]
return ok
}
func getIp(r *http.Request) string {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
if e, ok := err.(*net.AddrError); ok && e.Err == "missing port in address" {
return r.RemoteAddr
}
return ""
}
return ip
}
@@ -1,159 +0,0 @@
package message
import (
"github.com/m1k1o/neko/server/internal/http/legacy/types"
"github.com/pion/webrtc/v4"
)
type Message struct {
Event string `json:"event"`
}
type SystemInit struct {
Event string `json:"event"`
Locks map[string]string `json:"locks"`
ImplicitHosting bool `json:"implicit_hosting"`
FileTransfer bool `json:"file_transfer"`
HeartbeatInterval int `json:"heartbeat_interval"`
}
type SystemMessage struct {
Event string `json:"event"`
Title string `json:"title"`
Message string `json:"message"`
}
type SignalProvide struct {
Event string `json:"event"`
ID string `json:"id"`
SDP string `json:"sdp"`
Lite bool `json:"lite"`
ICE []webrtc.ICEServer `json:"ice"`
}
type SignalOffer struct {
Event string `json:"event"`
SDP string `json:"sdp"`
}
type SignalAnswer struct {
Event string `json:"event"`
DisplayName string `json:"displayname"`
SDP string `json:"sdp"`
}
type SignalCandidate struct {
Event string `json:"event"`
Data string `json:"data"`
}
type MembersList struct {
Event string `json:"event"`
Members []*types.Member `json:"members"`
}
type Member struct {
Event string `json:"event"`
*types.Member
}
type MemberDisconnected struct {
Event string `json:"event"`
ID string `json:"id"`
}
type Clipboard struct {
Event string `json:"event"`
Text string `json:"text"`
}
type Keyboard struct {
Event string `json:"event"`
Layout *string `json:"layout,omitempty"`
CapsLock *bool `json:"capsLock,omitempty"`
NumLock *bool `json:"numLock,omitempty"`
ScrollLock *bool `json:"scrollLock,omitempty"` // TODO: ScrollLock is deprecated.
}
type Control struct {
Event string `json:"event"`
ID string `json:"id"`
}
type ControlTarget struct {
Event string `json:"event"`
ID string `json:"id"`
Target string `json:"target"`
}
type ChatReceive struct {
Event string `json:"event"`
Content string `json:"content"`
}
type ChatSend struct {
Event string `json:"event"`
ID string `json:"id"`
Content string `json:"content"`
}
type EmoteReceive struct {
Event string `json:"event"`
Emote string `json:"emote"`
}
type EmoteSend struct {
Event string `json:"event"`
ID string `json:"id"`
Emote string `json:"emote"`
}
type FileTransferList struct {
Event string `json:"event"`
Cwd string `json:"cwd"`
UserDownload bool `json:"user_download"`
UserUpload bool `json:"user_upload"`
UserDelete bool `json:"user_delete"`
Files []types.FileListItem `json:"files"`
}
type Admin struct {
Event string `json:"event"`
ID string `json:"id"`
}
type AdminTarget struct {
Event string `json:"event"`
Target string `json:"target"`
ID string `json:"id"`
}
type AdminLock struct {
Event string `json:"event"`
Resource string `json:"resource"`
ID string `json:"id"`
}
type ScreenResolution struct {
Event string `json:"event"`
ID string `json:"id,omitempty"`
Width int `json:"width"`
Height int `json:"height"`
Rate int16 `json:"rate"`
}
type ScreenConfigurations struct {
Event string `json:"event"`
Configurations map[int]types.ScreenConfiguration `json:"configurations"`
}
type BroadcastStatus struct {
Event string `json:"event"`
URL string `json:"url"`
IsActive bool `json:"isActive"`
}
type BroadcastCreate struct {
Event string `json:"event"`
URL string `json:"url"`
}
-232
View File
@@ -1,232 +0,0 @@
package legacy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strings"
"sync"
oldTypes "github.com/m1k1o/neko/server/internal/http/legacy/types"
"github.com/m1k1o/neko/server/internal/api"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
)
var (
ErrWebsocketSend = fmt.Errorf("failed to send message to websocket")
ErrBackendRespone = fmt.Errorf("error response from backend")
)
type memberStruct struct {
member *oldTypes.Member
connected bool
sent bool
}
type session struct {
r *http.Request
h *LegacyHandler
logger zerolog.Logger
serverAddr string
pathPrefix string
id, ip string
token string
cookie string
name string
isAdmin bool
client *http.Client
lastHostID string
lockedControls bool
lockedLogins bool
lockedFileTransfer bool
sessions map[string]*memberStruct
muClient sync.Mutex
connClient *websocket.Conn
muBackend sync.Mutex
connBackend *websocket.Conn
}
func (h *LegacyHandler) newSession(r *http.Request) *session {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil // disable proxy for local requests
return &session{
r: r,
h: h,
logger: h.logger,
serverAddr: h.serverAddr,
pathPrefix: h.pathPrefix,
client: &http.Client{
Transport: transport,
},
sessions: make(map[string]*memberStruct),
}
}
func (s *session) req(method, reqPath string, headers http.Header, request io.Reader) (io.ReadCloser, http.Header, error) {
req, err := http.NewRequest(method, "http://"+s.serverAddr+path.Join(s.pathPrefix, reqPath), request)
if err != nil {
return nil, nil, err
}
for k, v := range headers {
req.Header[k] = v
}
if s.token != "" {
req.Header.Set("Authorization", "Bearer "+s.token)
} else if s.cookie != "" {
req.Header.Set("Cookie", s.cookie)
}
res, err := s.client.Do(req)
if err != nil {
return nil, nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
// try to unmarsal as json error message
var apiErr struct {
Message string `json:"message"`
}
if err := json.Unmarshal(body, &apiErr); err == nil {
return nil, nil, fmt.Errorf("%w: %s", ErrBackendRespone, apiErr.Message)
}
// return raw body if failed to unmarshal
return nil, nil, fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, strings.TrimSpace(string(body)))
}
return res.Body, res.Header, nil
}
func (s *session) apiReq(method, path string, request, response any) error {
return s.apiReqWithHeaders(method, path, request, response, nil)
}
func (s *session) apiReqWithHeaders(method, path string, request, response any, headers http.Header) error {
reqBody, err := json.Marshal(request)
if err != nil {
return err
}
if headers == nil {
headers = make(http.Header)
}
headers.Set("Content-Type", "application/json")
resBody, _, err := s.req(method, path, headers, bytes.NewReader(reqBody))
if err != nil {
return err
}
defer resBody.Close()
if resBody == nil {
return nil
}
if response == nil {
io.Copy(io.Discard, resBody)
return nil
}
return json.NewDecoder(resBody).Decode(response)
}
// send message to client (in old format)
func (s *session) toClient(payload any) error {
s.muClient.Lock()
defer s.muClient.Unlock()
err := s.connClient.WriteJSON(payload)
if err != nil {
return fmt.Errorf("%w: %s", ErrWebsocketSend, err)
}
return nil
}
// send message to backend (in new format)
func (s *session) toBackend(event string, payload any) error {
s.muBackend.Lock()
defer s.muBackend.Unlock()
rawPayload, err := json.Marshal(payload)
if err != nil {
return err
}
err = s.connBackend.WriteJSON(types.WebSocketMessage{
Event: event,
Payload: rawPayload,
})
if err != nil {
return fmt.Errorf("%w: %s", ErrWebsocketSend, err)
}
return nil
}
func (s *session) create(username, password string) error {
data := api.SessionDataPayload{}
if cookie := s.r.Header.Get("Cookie"); cookie != "" {
err := s.apiReqWithHeaders(http.MethodGet, "/api/whoami", nil, &data, http.Header{
"Cookie": []string{cookie},
})
if err == nil {
s.cookie = cookie
s.setSessionData(data)
return nil
}
}
err := s.apiReq(http.MethodPost, "/api/login", api.SessionLoginPayload{
Username: username,
Password: password,
}, &data)
if err != nil {
return err
}
s.setSessionData(data)
if s.token == "" {
return fmt.Errorf("token not found")
}
return nil
}
func (s *session) setSessionData(data api.SessionDataPayload) {
s.id, s.ip = data.ID, getIp(s.r)
s.h.sessionIPs[s.id] = s.ip // save session ip by id
s.token = data.Token
s.name = data.Profile.Name
s.isAdmin = data.Profile.IsAdmin
}
func (s *session) destroy() {
defer s.client.CloseIdleConnections()
// logout session
err := s.apiReq(http.MethodPost, "/api/logout", nil, nil)
if err != nil {
s.logger.Error().Err(err).Msg("failed to logout")
}
// remove session id from ip map
delete(s.h.sessionIPs, s.id)
}
@@ -1,63 +0,0 @@
package legacy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/m1k1o/neko/server/pkg/types"
)
func TestCreateUsesExistingSessionCookie(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/whoami":
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if got := r.Header.Get("Cookie"); got != "NEKO_SESSION=session-token" {
t.Fatalf("cookie = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "oauth:user-123",
"profile": types.MemberProfile{
Name: "Ada Lovelace",
IsAdmin: true,
},
})
case "/api/protected":
if got := r.Header.Get("Cookie"); got != "NEKO_SESSION=session-token" {
t.Fatalf("authenticated request cookie = %q", got)
}
_ = json.NewEncoder(w).Encode(true)
default:
http.NotFound(w, r)
}
}))
defer backend.Close()
request := httptest.NewRequest(http.MethodGet, "http://neko.test/ws", nil)
request.Header.Set("Cookie", "NEKO_SESSION=session-token")
handler := &LegacyHandler{sessionIPs: make(map[string]string)}
session := &session{
r: request,
h: handler,
serverAddr: strings.TrimPrefix(backend.URL, "http://"),
client: backend.Client(),
}
if err := session.create("", ""); err != nil {
t.Fatal(err)
}
if session.id != "oauth:user-123" || session.name != "Ada Lovelace" || !session.isAdmin {
t.Fatalf("session = %#v", session)
}
if session.cookie != "NEKO_SESSION=session-token" || session.token != "" {
t.Fatalf("authentication state = cookie %q token %q", session.cookie, session.token)
}
if err := session.apiReq(http.MethodGet, "/api/protected", nil, nil); err != nil {
t.Fatal(err)
}
}
@@ -1,39 +0,0 @@
package types
import "time"
type Stats struct {
Connections uint32 `json:"connections"`
Host string `json:"host"`
Members []*Member `json:"members"`
Banned map[string]string `json:"banned"` // IP -> session ID (that banned it)
Locked map[string]string `json:"locked"` // resource name -> session ID (that locked it)
ServerStartedAt time.Time `json:"server_started_at"`
LastAdminLeftAt *time.Time `json:"last_admin_left_at"`
LastUserLeftAt *time.Time `json:"last_user_left_at"`
ControlProtection bool `json:"control_protection"`
ImplicitControl bool `json:"implicit_control"`
}
type Member struct {
ID string `json:"id"`
Name string `json:"displayname"`
Avatar string `json:"avatar,omitempty"`
Admin bool `json:"admin"`
Muted bool `json:"muted"`
}
type FileListItem struct {
Filename string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size"`
}
type ScreenConfiguration struct {
Width int `json:"width"`
Height int `json:"height"`
Rates map[int]int16 `json:"rates"`
}
-386
View File
@@ -1,386 +0,0 @@
package legacy
import (
"encoding/json"
"fmt"
"net/http"
"github.com/pion/webrtc/v4"
oldEvent "github.com/m1k1o/neko/server/internal/http/legacy/event"
oldMessage "github.com/m1k1o/neko/server/internal/http/legacy/message"
"github.com/m1k1o/neko/server/internal/api/room"
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/types/event"
"github.com/m1k1o/neko/server/pkg/types/message"
)
func (s *session) wsToBackend(msg []byte) error {
header := oldMessage.Message{}
err := json.Unmarshal(msg, &header)
if err != nil {
return err
}
switch header.Event {
// Client Events
case oldEvent.CLIENT_HEARTBEAT:
// do nothing
return nil
// Signal Events
case oldEvent.SIGNAL_OFFER:
request := &oldMessage.SignalOffer{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(event.SIGNAL_OFFER, &message.SignalDescription{
SDP: request.SDP,
})
case oldEvent.SIGNAL_ANSWER:
request := &oldMessage.SignalAnswer{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
if request.DisplayName != "" {
// Update display name if it is not set
if s.name == "" {
err = s.apiReq(http.MethodPost, "/api/profile", map[string]any{
"name": request.DisplayName,
}, nil)
if err != nil {
return err
}
s.name = request.DisplayName
}
}
// try to set legacy video stream, if it fails, it will be ignored
if err := s.toBackend(event.SIGNAL_VIDEO, &message.SignalVideo{
PeerVideoRequest: types.PeerVideoRequest{
Selector: &types.StreamSelector{
Type: types.StreamSelectorTypeExact,
ID: "legacy",
},
},
}); err != nil {
return err
}
return s.toBackend(event.SIGNAL_ANSWER, &message.SignalDescription{
SDP: request.SDP,
})
case oldEvent.SIGNAL_CANDIDATE:
request := &oldMessage.SignalCandidate{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
var candidate webrtc.ICECandidateInit
err = json.Unmarshal([]byte(request.Data), &candidate)
if err != nil {
return err
}
return s.toBackend(event.SIGNAL_CANDIDATE, &message.SignalCandidate{
ICECandidateInit: candidate,
})
// Control Events
case oldEvent.CONTROL_RELEASE:
return s.toBackend(event.CONTROL_RELEASE, nil)
case oldEvent.CONTROL_REQUEST:
return s.toBackend(event.CONTROL_REQUEST, nil)
case oldEvent.CONTROL_GIVE:
request := &oldMessage.Control{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/room/control/give/"+request.ID, nil, nil)
case oldEvent.CONTROL_CLIPBOARD:
request := &oldMessage.Clipboard{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(event.CLIPBOARD_SET, &message.ClipboardData{
Text: request.Text,
})
case oldEvent.CONTROL_KEYBOARD:
request := &oldMessage.Keyboard{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
if request.Layout != nil {
err = s.toBackend(event.KEYBOARD_MAP, &message.KeyboardMap{
KeyboardMap: types.KeyboardMap{
Layout: *request.Layout,
},
})
if err != nil {
return err
}
}
if request.CapsLock != nil || request.NumLock != nil || request.ScrollLock != nil {
err = s.toBackend(event.KEYBOARD_MODIFIERS, &message.KeyboardModifiers{
KeyboardModifiers: types.KeyboardModifiers{
CapsLock: request.CapsLock,
NumLock: request.NumLock,
// ScrollLock: request.ScrollLock, // ScrollLock is deprecated.
},
})
if err != nil {
return err
}
}
return nil
// Chat Events
case oldEvent.CHAT_MESSAGE:
request := &oldMessage.ChatReceive{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(chat.CHAT_MESSAGE, &chat.Content{
Text: request.Content,
})
case oldEvent.CHAT_EMOTE:
request := &oldMessage.EmoteReceive{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
// loopback emote
err = s.toClient(&oldMessage.EmoteSend{
Event: oldEvent.CHAT_EMOTE,
ID: s.id,
Emote: request.Emote,
})
if err != nil {
return err
}
// broadcast emote to other users
return s.toBackend(event.SEND_BROADCAST, &message.SendBroadcast{
Sender: s.id,
Subject: "emote",
Body: request.Emote,
})
// File Transfer Events
case oldEvent.FILETRANSFER_REFRESH:
return s.toBackend(filetransfer.FILETRANSFER_UPDATE, nil)
// Open In App Events
case openinapp.OPENINAPP_OPENLINK:
request := &openinapp.Url{}
if err := json.Unmarshal(msg, request); err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/openinapp/openlink", request, nil)
// Screen Events
case oldEvent.SCREEN_RESOLUTION:
response := &types.ScreenSize{}
err := s.apiReq(http.MethodGet, "/api/room/screen", nil, response)
if err != nil {
return err
}
return s.toClient(&oldMessage.ScreenResolution{
Event: oldEvent.SCREEN_RESOLUTION,
Width: response.Width,
Height: response.Height,
Rate: response.Rate,
})
case oldEvent.SCREEN_CONFIGURATIONS:
response := &[]types.ScreenSize{}
err := s.apiReq(http.MethodGet, "/api/room/screen/configurations", nil, response)
if err != nil {
return err
}
return s.toClient(&oldMessage.ScreenConfigurations{
Event: oldEvent.SCREEN_CONFIGURATIONS,
Configurations: screenConfigurations(*response),
})
case oldEvent.SCREEN_SET:
request := &oldMessage.ScreenResolution{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(event.SCREEN_SET, &message.ScreenSize{
ScreenSize: types.ScreenSize{
Width: request.Width,
Height: request.Height,
Rate: request.Rate,
},
})
// Broadcast Events
case oldEvent.BROADCAST_CREATE:
request := &oldMessage.BroadcastCreate{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/room/broadcast/start", room.BroadcastStatusPayload{
URL: request.URL,
IsActive: true,
}, nil)
case oldEvent.BROADCAST_DESTROY:
return s.apiReq(http.MethodPost, "/api/room/broadcast/stop", nil, nil)
// Admin Events
case oldEvent.ADMIN_LOCK:
request := &oldMessage.AdminLock{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
data := map[string]any{}
switch request.Resource {
case "login":
data["locked_logins"] = true
case "control":
data["locked_controls"] = true
case "file_transfer":
data["plugins"] = map[string]any{
"filetransfer.enabled": false,
}
default:
return fmt.Errorf("unknown resource: %s", request.Resource)
}
return s.apiReq(http.MethodPost, "/api/room/settings", data, nil)
case oldEvent.ADMIN_UNLOCK:
request := &oldMessage.AdminLock{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
data := map[string]any{}
switch request.Resource {
case "login":
data["locked_logins"] = false
case "control":
data["locked_controls"] = false
case "file_transfer":
data["plugins"] = map[string]any{
"filetransfer.enabled": true,
}
default:
return fmt.Errorf("unknown resource: %s", request.Resource)
}
return s.apiReq(http.MethodPost, "/api/room/settings", data, nil)
case oldEvent.ADMIN_CONTROL:
return s.apiReq(http.MethodPost, "/api/room/control/take", nil, nil)
case oldEvent.ADMIN_RELEASE:
return s.apiReq(http.MethodPost, "/api/room/control/reset", nil, nil)
case oldEvent.ADMIN_GIVE:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/room/control/give/"+request.ID, nil, nil)
case oldEvent.ADMIN_BAN:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
err = s.h.ban(request.ID)
if err != nil {
return err
}
fallthrough // continue to kick
case oldEvent.ADMIN_KICK:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/members/"+request.ID, map[string]any{
"can_login": false,
}, nil)
case oldEvent.ADMIN_MUTE:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/members/"+request.ID, map[string]any{
"plugins": map[string]any{
"chat.can_send": false,
},
}, nil)
case oldEvent.ADMIN_UNMUTE:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/members/"+request.ID, map[string]any{
"plugins": map[string]any{
"chat.can_send": true,
},
}, nil)
default:
return fmt.Errorf("unknown event type: %s", header.Event)
}
}
-778
View File
@@ -1,778 +0,0 @@
package legacy
import (
"encoding/json"
"errors"
"fmt"
"github.com/pion/webrtc/v4"
"github.com/spf13/viper"
oldEvent "github.com/m1k1o/neko/server/internal/http/legacy/event"
oldMessage "github.com/m1k1o/neko/server/internal/http/legacy/message"
oldTypes "github.com/m1k1o/neko/server/internal/http/legacy/types"
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/types/event"
"github.com/m1k1o/neko/server/pkg/types/message"
)
func profileToMember(id string, profile types.MemberProfile) (*oldTypes.Member, error) {
settings := chat.Settings{
CanSend: true, // defaults to true
CanReceive: true, // defaults to true
}
err := profile.Plugins.Unmarshal(chat.PluginName, &settings)
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
return nil, fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", chat.PluginName, err)
}
return &oldTypes.Member{
ID: id,
Name: profile.Name,
Avatar: profile.Avatar,
Admin: profile.IsAdmin,
Muted: !settings.CanSend,
}, nil
}
func screenConfigurations(screenSizes []types.ScreenSize) map[int]oldTypes.ScreenConfiguration {
rates := map[string]map[int16]struct{}{}
for _, size := range screenSizes {
key := fmt.Sprintf("%dx%d", size.Width, size.Height)
if _, ok := rates[key]; !ok {
rates[key] = map[int16]struct{}{}
}
rates[key][size.Rate] = struct{}{}
}
usedScreenSizes := map[string]struct{}{}
screenSizesList := map[int]oldTypes.ScreenConfiguration{}
for i, size := range screenSizes {
key := fmt.Sprintf("%dx%d", size.Width, size.Height)
if _, ok := usedScreenSizes[key]; ok {
continue
}
usedScreenSizes[key] = struct{}{}
c, ratesMap := 0, map[int]int16{}
for rate := range rates[key] {
ratesMap[c] = rate
c++
}
screenSizesList[i] = oldTypes.ScreenConfiguration{
Width: size.Width,
Height: size.Height,
Rates: ratesMap,
}
}
return screenSizesList
}
func (s *session) settingsToLocks(settings types.Settings) (map[string]string, error) {
//
// FileTransfer
//
filetransferSettings := filetransfer.Settings{
Enabled: true, // defaults to true
}
err := settings.Plugins.Unmarshal(filetransfer.PluginName, &filetransferSettings)
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
return nil, fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", filetransfer.PluginName, err)
}
//
// Locks
//
locks := map[string]string{}
if settings.LockedLogins {
locks["login"] = "" // TODO: We don't know who locked the login.
s.lockedLogins = true
}
if settings.LockedControls {
locks["control"] = "" // TODO: We don't know who locked the control.
s.lockedControls = true
}
if !filetransferSettings.Enabled {
locks["file_transfer"] = "" // TODO: We don't know who locked the file transfer.
s.lockedFileTransfer = true
}
return locks, nil
}
func (s *session) sendControlHost(request message.ControlHost) error {
lastHostID := s.lastHostID
if request.HasHost {
s.lastHostID = request.ID
if request.ID == request.HostID {
if request.ID == lastHostID || lastHostID == "" {
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_LOCKED,
ID: request.HostID,
})
} else {
return s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_CONTROL,
ID: request.ID,
Target: lastHostID,
})
}
} else if request.ID == "" {
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_LOCKED,
ID: request.HostID,
})
} else {
return s.toClient(&oldMessage.ControlTarget{
Event: oldEvent.CONTROL_GIVE,
ID: request.ID,
Target: request.HostID,
})
}
}
if request.ID != "" {
s.lastHostID = ""
if request.ID == lastHostID {
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_RELEASE,
ID: request.ID,
})
} else {
return s.toClient(&oldMessage.Control{
Event: oldEvent.ADMIN_RELEASE,
ID: request.ID,
})
}
}
return nil
}
func (s *session) wsToClient(msg []byte) error {
data := types.WebSocketMessage{}
err := json.Unmarshal(msg, &data)
if err != nil {
return err
}
switch data.Event {
// System Events
case event.SYSTEM_DISCONNECT:
request := &message.SystemDisconnect{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
// if profile change is the reason for disconnect, it's a kick event
if request.Message == "profile changed" {
request.Message = "kicked"
}
return s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Message: request.Message,
})
case event.SYSTEM_INIT:
request := &message.SystemInit{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
//
// MembersList
//
membersList := []*oldTypes.Member{}
s.sessions = map[string]*memberStruct{}
for id, session := range request.Sessions {
if !session.State.IsConnected {
continue
}
member, err := profileToMember(id, session.Profile)
if err != nil {
return err
}
membersList = append(membersList, member)
s.sessions[id] = &memberStruct{
sent: member.Name != "",
connected: true,
member: member,
}
}
err = s.toClient(&oldMessage.MembersList{
Event: oldEvent.MEMBER_LIST,
Members: membersList,
})
if err != nil {
return err
}
//
// ScreenSize
//
err = s.toClient(&oldMessage.ScreenResolution{
Event: oldEvent.SCREEN_RESOLUTION,
Width: request.ScreenSize.Width,
Height: request.ScreenSize.Height,
Rate: request.ScreenSize.Rate,
})
if err != nil {
return err
}
// actually its already set when we create the session
s.id = request.SessionId
//
// ControlHost
//
err = s.sendControlHost(request.ControlHost)
if err != nil {
return err
}
locks, err := s.settingsToLocks(request.Settings)
if err != nil {
return err
}
return s.toClient(&oldMessage.SystemInit{
Event: oldEvent.SYSTEM_INIT,
ImplicitHosting: request.Settings.ImplicitHosting,
Locks: locks,
// TODO: hack - we don't know if file transfer is enabled, we would need to check the global config.
FileTransfer: viper.GetBool("filetransfer.enabled") || (viper.GetBool("legacy") && viper.GetBool("file_transfer_enabled")),
HeartbeatInterval: request.Settings.HeartbeatInterval,
})
case event.SYSTEM_ADMIN:
request := &message.SystemAdmin{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
//
// ScreenSizesList
//
err = s.toClient(&oldMessage.ScreenConfigurations{
Event: oldEvent.SCREEN_CONFIGURATIONS,
Configurations: screenConfigurations(request.ScreenSizesList),
})
if err != nil {
return err
}
//
// BroadcastStatus
//
return s.toClient(&oldMessage.BroadcastStatus{
Event: oldEvent.BROADCAST_STATUS,
URL: request.BroadcastStatus.URL,
IsActive: request.BroadcastStatus.IsActive,
})
// Member Events
case event.SESSION_CREATED:
request := &message.SessionData{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
member, err := profileToMember(request.ID, request.Profile)
if err != nil {
return err
}
// only save session - will be notified on connect
s.sessions[request.ID] = &memberStruct{
member: member,
}
return nil
case event.SESSION_DELETED:
request := &message.SessionID{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
// only continue if session is in the list - should have been already removed
if _, ok := s.sessions[request.ID]; !ok {
return nil
}
delete(s.sessions, request.ID)
return s.toClient(&oldMessage.MemberDisconnected{
Event: oldEvent.MEMBER_DISCONNECTED,
ID: request.ID,
})
case event.SESSION_PROFILE:
request := &message.MemberProfile{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
// session profile is expected to change when updating a name after connecting
m, ok := s.sessions[request.ID]
if !ok || m == nil {
return nil
}
// update member profile
member, err := profileToMember(request.ID, request.MemberProfile)
if err != nil {
return err
}
mutedChanged := m.member.Muted != member.Muted
m.member = member
if m.connected && !m.sent && member.Name != "" {
m.sent = true
// oldEvent.MEMBER_CONNECTED if not sent already
err = s.toClient(&oldMessage.Member{
Event: oldEvent.MEMBER_CONNECTED,
Member: member,
})
if err != nil {
return err
}
}
if mutedChanged && member.Muted {
return s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_MUTE,
ID: "", // TODO: We don't know who (un)muted the user.
Target: request.ID,
})
} else if mutedChanged && !member.Muted {
return s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_UNMUTE,
ID: "", // TODO: We don't know who (un)muted the user.
Target: request.ID,
})
}
return nil
case event.SESSION_STATE:
request := &message.SessionState{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
m, ok := s.sessions[request.ID]
if !ok {
return nil
}
if request.IsConnected {
m.connected = true
if m.member.Muted {
err = s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_MUTE,
ID: "", // TODO: We don't know who (un)muted the user.
Target: request.ID,
})
if err != nil {
return err
}
}
if !m.sent && m.member.Name != "" {
m.sent = true
// oldEvent.MEMBER_CONNECTED if not sent already
return s.toClient(&oldMessage.Member{
Event: oldEvent.MEMBER_CONNECTED,
Member: m.member,
})
}
}
if !request.IsConnected {
delete(s.sessions, request.ID)
// oldEvent.MEMBER_DISCONNECTED if nor sent already
return s.toClient(&oldMessage.MemberDisconnected{
Event: oldEvent.MEMBER_DISCONNECTED,
ID: request.ID,
})
}
return nil
// Signal Events
case event.SIGNAL_VIDEO, event.SIGNAL_AUDIO:
// ignore video and audio signals, because they are not part of the legacy protocol
return nil
case event.SIGNAL_OFFER:
request := &message.SignalDescription{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.SignalOffer{
Event: oldEvent.SIGNAL_OFFER,
SDP: request.SDP,
})
case event.SIGNAL_ANSWER:
request := &message.SignalDescription{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.SignalAnswer{
Event: oldEvent.SIGNAL_ANSWER,
DisplayName: s.name,
SDP: request.SDP,
})
case event.SIGNAL_CANDIDATE:
request := &message.SignalCandidate{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
json, err := json.Marshal(request.ICECandidateInit)
if err != nil {
return err
}
return s.toClient(&oldMessage.SignalCandidate{
Event: oldEvent.SIGNAL_CANDIDATE,
Data: string(json),
})
case event.SIGNAL_PROVIDE:
request := &message.SignalProvide{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
iceServers := []webrtc.ICEServer{}
for _, ice := range request.ICEServers {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: ice.URLs,
Username: ice.Username,
Credential: ice.Credential,
CredentialType: webrtc.ICECredentialTypePassword,
})
}
return s.toClient(&oldMessage.SignalProvide{
Event: oldEvent.SIGNAL_PROVIDE,
ID: s.id, // SessionId
SDP: request.SDP,
Lite: len(iceServers) == 0, // if no ICE servers are provided, it's a lite offer
ICE: iceServers,
})
// Control Events
case event.CLIPBOARD_UPDATED:
request := &message.ClipboardData{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.Clipboard{
Event: oldEvent.CONTROL_CLIPBOARD,
Text: request.Text,
})
case event.CONTROL_HOST:
request := &message.ControlHost{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.sendControlHost(*request)
case event.CONTROL_REQUEST:
request := &message.SessionID{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
if s.id == request.ID {
// if i am the one that is requesting, send CONTROL_REQUEST to me
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_REQUEST,
ID: request.ID,
})
} else {
// if not, let me know someone else is requesting
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_REQUESTING,
ID: request.ID,
})
}
// Chat Events
case chat.CHAT_INIT:
// ignore chat init, because it is not part of the legacy protocol
return nil
case chat.CHAT_MESSAGE:
request := &chat.Message{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.ChatSend{
Event: oldEvent.CHAT_MESSAGE,
ID: request.ID,
Content: request.Content.Text,
})
case event.SEND_BROADCAST:
request := &message.SendBroadcast{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
if request.Subject == "emote" {
return s.toClient(&oldMessage.EmoteSend{
Event: oldEvent.CHAT_EMOTE,
ID: request.Sender,
Emote: request.Body.(string),
})
}
return nil
// File Transfer Events
case filetransfer.FILETRANSFER_UPDATE:
request := &filetransfer.Message{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
files := []oldTypes.FileListItem{}
for _, file := range request.Files {
var itemType string
switch file.Type {
case filetransfer.ItemTypeFile:
itemType = "file"
case filetransfer.ItemTypeDir:
itemType = "dir"
}
files = append(files, oldTypes.FileListItem{
Filename: file.Name,
Type: itemType,
Size: file.Size,
})
}
return s.toClient(&oldMessage.FileTransferList{
Event: oldEvent.FILETRANSFER_LIST,
Cwd: request.RootDir,
UserDownload: request.UserDownload,
UserUpload: request.UserUpload,
UserDelete: request.UserDelete,
Files: files,
})
// Open In App Events
case openinapp.OPENINAPP_INIT:
request := &openinapp.Init{}
if err := json.Unmarshal(data.Payload, request); err != nil {
return err
}
return s.toClient(&struct {
Event string `json:"event"`
Enabled bool `json:"enabled"`
}{
Event: openinapp.OPENINAPP_INIT,
Enabled: request.Enabled,
})
// Screen Events
case event.SCREEN_UPDATED:
request := &message.ScreenSizeUpdate{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.ScreenResolution{
Event: oldEvent.SCREEN_RESOLUTION,
ID: request.ID,
Width: request.ScreenSize.Width,
Height: request.ScreenSize.Height,
Rate: request.ScreenSize.Rate,
})
// Broadcast Events
case event.BROADCAST_STATUS:
request := &message.BroadcastStatus{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.BroadcastStatus{
Event: oldEvent.BROADCAST_STATUS,
URL: request.URL,
IsActive: request.IsActive,
})
// Admin Events
case event.SYSTEM_SETTINGS:
request := &message.SystemSettingsUpdate{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
if s.lockedControls != request.LockedControls {
s.lockedControls = request.LockedControls
if request.LockedControls {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_LOCK,
Resource: "control",
ID: request.ID,
})
} else {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_UNLOCK,
Resource: "control",
ID: request.ID,
})
}
if err != nil {
return err
}
}
if s.lockedLogins != request.LockedLogins {
s.lockedLogins = request.LockedLogins
if request.LockedLogins {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_LOCK,
Resource: "login",
ID: request.ID,
})
} else {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_UNLOCK,
Resource: "login",
ID: request.ID,
})
}
if err != nil {
return err
}
}
//
// FileTransfer
//
filetransferSettings := filetransfer.Settings{
Enabled: true, // defaults to true
}
err = request.Settings.Plugins.Unmarshal(filetransfer.PluginName, &filetransferSettings)
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
return fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", filetransfer.PluginName, err)
}
if s.lockedFileTransfer != !filetransferSettings.Enabled {
s.lockedFileTransfer = !filetransferSettings.Enabled
if !filetransferSettings.Enabled {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_LOCK,
Resource: "file_transfer",
ID: request.ID,
})
} else {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_UNLOCK,
Resource: "file_transfer",
ID: request.ID,
})
}
if err != nil {
return err
}
}
return nil
/*
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_BAN,
})
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_KICK,
})
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_MUTE,
})
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_UNMUTE,
})
*/
case event.SYSTEM_HEARTBEAT:
return nil
default:
return fmt.Errorf("unknown event type: %s", data.Event)
}
}
@@ -1,20 +0,0 @@
package legacy
import (
"testing"
"github.com/m1k1o/neko/server/pkg/types"
)
func TestProfileToMemberPreservesAvatar(t *testing.T) {
member, err := profileToMember("oauth:user-123", types.MemberProfile{
Name: "Ada Lovelace",
Avatar: "https://example.test/ada.png",
})
if err != nil {
t.Fatal(err)
}
if member.Name != "Ada Lovelace" || member.Avatar != "https://example.test/ada.png" {
t.Fatalf("member = %#v", member)
}
}
+9 -27
View File
@@ -2,18 +2,17 @@ package http
import (
"context"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/http/legacy"
"github.com/m1k1o/neko/server/pkg/types"
)
@@ -56,7 +55,13 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c
router.Get("/api/ws", WebSocketManager.Upgrade(func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "" || config.AllowOrigin(origin)
if origin == "" || config.AllowOrigin(origin) {
return true
}
// by default, only same-origin browser connections are accepted
u, err := url.Parse(origin)
return err == nil && u.Host != "" && strings.EqualFold(u.Host, r.Host)
}))
batch := batchHandler{
@@ -139,24 +144,6 @@ func (manager *HttpManagerCtx) Start() {
}
}()
manager.logger.Info().Msgf("https listening on %s", manager.http.Addr)
// if we have legacy mode, we need to start local http server too
if viper.GetBool("legacy") {
// create a listener for the API server with a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
manager.logger.Panic().Err(err).Msg("unable to start legacy http proxy")
}
go func() {
if err := http.Serve(listener, manager.router); err != http.ErrServerClosed {
manager.logger.Panic().Err(err).Msg("unable to start http server")
}
}()
manager.logger.Info().Msgf("legacy proxy listening on %s", listener.Addr().String())
legacy.New(listener.Addr().String(), manager.config.PathPrefix).Route(manager.router)
}
} else {
go func() {
if err := manager.http.ListenAndServe(); err != http.ErrServerClosed {
@@ -164,11 +151,6 @@ func (manager *HttpManagerCtx) Start() {
}
}()
manager.logger.Info().Msgf("http listening on %s", manager.http.Addr)
// start legacy proxy if enabled
if viper.GetBool("legacy") {
legacy.New(manager.http.Addr, manager.config.PathPrefix).Route(manager.router)
}
}
}
+21
View File
@@ -109,3 +109,24 @@ func TestWebSocketOriginAllowsInternalDialWithoutOrigin(t *testing.T) {
t.Fatal("unconfigured browser Origin was accepted")
}
}
func TestWebSocketOriginDefaultsToSameOrigin(t *testing.T) {
webSocketManager := &originCapturingWebSocketManager{}
serverConfig := &config.Server{}
memberConfig := &config.Member{}
sessionManager := session.New(&config.Session{})
apiManager := api.New(sessionManager, member.New(sessionManager, memberConfig), nil, nil, memberConfig, serverConfig)
New(webSocketManager, apiManager, serverConfig, memberConfig)
request := httptest.NewRequest(http.MethodGet, "http://neko.example.test/api/ws", nil)
request.Header.Set("Origin", "http://neko.example.test")
if !webSocketManager.checkOrigin(request) {
t.Fatal("same Origin was rejected")
}
request.Header.Set("Origin", "https://attacker.example")
if webSocketManager.checkOrigin(request) {
t.Fatal("cross-site Origin was accepted with empty CORS")
}
}
+6
View File
@@ -168,6 +168,12 @@ func (service *Service) Complete(ctx context.Context, stateToken, code string) (
email = claim(idClaims, "email")
}
// Providers that omit email_verified are trusted; only an explicit false is rejected.
if email != "" && (claimFalse(claims, "email_verified") || claimFalse(idClaims, "email_verified")) {
log.Warn().Msg("OAuth provider reported the email as unverified; ignoring it for admin_emails/user_emails")
email = ""
}
if email == "" && (len(config.AdminEmails) > 0 || len(config.UserEmails) > 0) {
log.Warn().Msg("OAuth admin_emails/user_emails are configured but the provider did not return an 'email' claim; request the 'email' scope")
}
+15
View File
@@ -70,6 +70,21 @@ func boolClaim(claims map[string]any, field string) bool {
return false
}
// claimFalse reports whether the claim is present and explicitly false.
func claimFalse(claims map[string]any, field string) bool {
value, ok := claims[field]
if !ok {
return false
}
switch value := value.(type) {
case bool:
return !value
case string:
return strings.EqualFold(value, "false")
}
return false
}
func administrator(email string, isAdmin bool, emails []string) bool {
if isAdmin {
return true
@@ -0,0 +1,22 @@
package oauth
import "testing"
func TestClaimFalse(t *testing.T) {
cases := []struct {
name string
claims map[string]any
want bool
}{
{"absent", map[string]any{}, false},
{"true", map[string]any{"email_verified": true}, false},
{"false", map[string]any{"email_verified": false}, true},
{"string true", map[string]any{"email_verified": "true"}, false},
{"string false", map[string]any{"email_verified": "False"}, true},
}
for _, tc := range cases {
if got := claimFalse(tc.claims, "email_verified"); got != tc.want {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
+5 -22
View File
@@ -47,16 +47,11 @@ func (session *SessionCtx) profileChanged() {
}
if (!session.profile.CanConnect || !session.profile.CanLogin || !session.profile.CanWatch) && session.state.IsWatching {
// TODO: Needed for legacy implementation. Websocket must die before webrtc and deliver signal close message
// otherwise webrtc destroy would trigger websocket reconnect. In case of kick event, webrtc destroy is called
// before websocket destroy that delivers the information about the kick.
time.AfterFunc(time.Second, func() {
// The peer may have been removed if the user disconnected while
// waiting for the delayed teardown.
if webrtcPeer := session.GetWebRTCPeer(); webrtcPeer != nil {
webrtcPeer.Destroy()
}
})
// The peer may have been removed if the user disconnected while
// waiting for the delayed teardown.
if webrtcPeer := session.GetWebRTCPeer(); webrtcPeer != nil {
webrtcPeer.Destroy()
}
}
if (!session.profile.CanConnect || !session.profile.CanLogin) && session.state.IsConnected {
@@ -77,18 +72,6 @@ func (session *SessionCtx) IsHost() bool {
return session.manager.isHost(session)
}
// only needed for legacy webrtc handler
func (session *SessionCtx) LegacyIsHost() bool {
settings := session.manager.Settings()
if !session.profile.CanHost || session.PrivateModeEnabled() {
return false
}
if settings.LockedControls && !session.profile.IsAdmin {
return false
}
return settings.ImplicitHosting || session.manager.isHost(session)
}
func (session *SessionCtx) SetAsHost() {
session.manager.setHost(session, session)
}
-140
View File
@@ -1,140 +0,0 @@
package webrtc
import (
"bytes"
"encoding/binary"
"strconv"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/rs/zerolog"
)
const (
OP_MOVE = 0x01
OP_SCROLL = 0x02
OP_KEY_DOWN = 0x03
OP_KEY_UP = 0x04
OP_KEY_CLK = 0x05
)
type PayloadHeader struct {
Event uint8
Length uint16
}
type PayloadMove struct {
PayloadHeader
X uint16
Y uint16
}
type PayloadScroll struct {
PayloadHeader
X int16
Y int16
}
type PayloadKey struct {
PayloadHeader
Key uint64 // TODO: uint32
}
func (manager *WebRTCManagerCtx) handleLegacy(
logger zerolog.Logger, data []byte,
session types.Session,
) error {
// continue only if session is host
if !session.LegacyIsHost() {
return nil
}
buffer := bytes.NewBuffer(data)
header := &PayloadHeader{}
hbytes := make([]byte, 3)
if _, err := buffer.Read(hbytes); err != nil {
return err
}
if err := binary.Read(bytes.NewBuffer(hbytes), binary.LittleEndian, header); err != nil {
return err
}
buffer = bytes.NewBuffer(data)
switch header.Event {
case OP_MOVE:
payload := &PayloadMove{}
if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil {
return err
}
manager.desktop.Move(int(payload.X), int(payload.Y))
case OP_SCROLL:
payload := &PayloadScroll{}
if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil {
return err
}
logger.
Trace().
Str("x", strconv.Itoa(int(payload.X))).
Str("y", strconv.Itoa(int(payload.Y))).
Msg("scroll")
manager.desktop.Scroll(int(payload.X), int(payload.Y), false)
case OP_KEY_DOWN:
payload := &PayloadKey{}
if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil {
return err
}
if payload.Key < 8 {
err := manager.desktop.ButtonDown(uint32(payload.Key))
if err != nil {
logger.Warn().Err(err).Msg("button down failed")
return nil
}
logger.Trace().Msgf("button down %d", payload.Key)
} else {
err := manager.desktop.KeyDown(uint32(payload.Key))
if err != nil {
logger.Warn().Err(err).Msg("key down failed")
return nil
}
logger.Trace().Msgf("key down %d", payload.Key)
}
case OP_KEY_UP:
payload := &PayloadKey{}
err := binary.Read(buffer, binary.LittleEndian, payload)
if err != nil {
return err
}
if payload.Key < 8 {
err := manager.desktop.ButtonUp(uint32(payload.Key))
if err != nil {
logger.Warn().Err(err).Msg("button up failed")
return nil
}
logger.Trace().Msgf("button up %d", payload.Key)
} else {
err := manager.desktop.KeyUp(uint32(payload.Key))
if err != nil {
logger.Warn().Err(err).Msg("key up failed")
return nil
}
logger.Trace().Msgf("key up %d", payload.Key)
}
case OP_KEY_CLK:
// unused
break
}
return nil
}
-18
View File
@@ -18,7 +18,6 @@ import (
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/webrtc/cursor"
@@ -485,23 +484,6 @@ func (manager *WebRTCManagerCtx) CreatePeer(session types.Session) (*webrtc.Sess
connection.OnDataChannel(func(dc *webrtc.DataChannel) {
logger.Info().Interface("data_channel", dc).Msg("got remote data channel")
//
// old implementation created a new data channel on client side
// new implementation creates a new data channel on server side
//
if viper.GetBool("legacy") {
// handle legacy data channel
dc.OnMessage(func(message webrtc.DataChannelMessage) {
if err := manager.handleLegacy(logger, message.Data, session); err != nil {
logger.Err(err).Msg("data handle failed")
}
})
// handle legacy data channel
peer.dataChannel = dc
}
})
var once sync.Once
-1
View File
@@ -69,7 +69,6 @@ type Session interface {
Profile() MemberProfile
State() SessionState
IsHost() bool
LegacyIsHost() bool
SetAsHost()
SetAsHostBy(session Session)
ClearHost()
+2 -1
View File
@@ -1,3 +1,4 @@
module github.com/m1k1o/neko/utils/docker
go 1.25.1
go 1.26.0
@@ -234,6 +234,10 @@ The sign-in endpoint is `GET /api/oauth/login`; the callback endpoint is `GET /a
`admin_emails` and `user_emails` are matched against the `email` claim. Most providers only include that claim if the `email` scope is requested, so `scopes` must include `"email"` (in addition to `"openid"`) or the claim will be empty and these lists will never match.
:::
:::warning
If the provider returns `email_verified: false`, Neko ignores the email, so it cannot match `admin_emails` or `user_emails`. If the claim is missing, the email is trusted, because many providers (including generic OAuth2 providers such as GitHub) do not return it at all. In that case `admin_emails` and `user_emails` rely on the identity provider having verified the address. If users can register or edit their email without verification on a provider that does not send `email_verified`, anyone could claim an address listed in `admin_emails` and obtain an administrator session. Require email verification at the provider or do not use these lists.
:::
For OAuth sessions, `GET /api/whoami` includes `extra_data`: the merged userinfo and ID-token claims, with userinfo values taking precedence. It is returned only to the authenticated session owner and is intended for profile-field troubleshooting. Do not place secrets in provider profile claims.
For non-OIDC providers, set `authorization_url`, `token_url`, `userinfo_url`, and optionally `redirect_url` directly. For GitHub, for example, use `read:user` as a scope and set `subject_field: id`, `username_field: login`, and `avatar_field: avatar_url`.
+1 -1
View File
@@ -43,7 +43,7 @@
--plugins.required if true, neko will exit if there is an error when loading a plugin
--server.bind string address/port/socket to serve neko (default "127.0.0.1:8080")
--server.cert string path to the SSL cert used to secure the neko server
--server.cors strings list of allowed origins for CORS, if empty CORS is disabled, if '*' is present all origins are allowed
--server.cors strings list of allowed origins for CORS and WebSocket connections, if empty only same-origin WebSocket connections are accepted, if '*' is present all origins are allowed
--server.key string path to the SSL key used to secure the neko server
--server.metrics enable prometheus metrics available at /metrics (default true)
--server.path_prefix string path prefix for HTTP requests (default "/")
+2 -2
View File
@@ -20,7 +20,7 @@ The production build is written to `client/dist/`.
## Backend
**Requires:** [Go](https://golang.org/) 1.25+ and the following system libraries (Debian/Ubuntu):
**Requires:** [Go](https://golang.org/) 1.26+ and the following system libraries (Debian/Ubuntu):
```bash
apt-get install -y --no-install-recommends \
@@ -58,7 +58,7 @@ The repository uses a [Dockerfile template](https://github.com/m1k1o/neko/blob/m
### Requirements
- [Go](https://golang.org/) 1.25+ (for the template pre-processor)
- [Go](https://golang.org/) 1.26+ (for the template pre-processor)
- [Node.js](https://nodejs.org/) 24 LTS (22.18 or newer; for the frontend, unless you supply a pre-built `client/dist/`)
- [Docker](https://www.docker.com/) with BuildKit enabled
+6 -101
View File
@@ -1,14 +1,6 @@
# Migration from V2
Currently, Neko is in compatibility mode, meaning that as soon as a single V2 configuration option is set, the legacy mode is enabled. This approach allows for a smooth transition from V2 to V3, where it does not expose the V2 API for new users but still allows existing users who use the old configuration to continue using it as before.
The legacy mode includes a compatibility layer that allows V2 clients to connect to V3. Currently, the **client is not compatible with V3**, so the legacy mode is enabled by default. It can be explicitly enabled or disabled by setting the `NEKO_LEGACY` environment variable to `true` or `false`.
:::warning
The legacy mode is **still used by the client**. It is recommended to migrate to the new configuration options, but do not disable the legacy mode unless you are using a new client that is compatible with V3 (e.g., [demodesk/neko-client](https://github.com/demodesk/neko-client)). Once the new client is released, the legacy mode will be automatically removed from the server.
:::
If you set both V3 and V2 configuration options, the V2 configuration options will take precedence over the V3 configuration options. This is to ensure that the legacy mode works as expected and does not break existing configurations.
Neko v3 accepts many V2 configuration options as deprecated aliases, which are mapped to their V3 equivalents. When both forms are set, the V3 option takes precedence. The V2 API and client compatibility layer have been removed; clients must use the V3 API.
## Docker Images {#docker}
@@ -20,7 +12,7 @@ All applications available in the V2 images are also available in the V3 images.
## Configuration {#config}
V3 is compatible with V2 configuration options when legacy support is enabled. You should be able to run V3 with the V2 configuration without any issues.
V3 continues to accept the V2 configuration aliases listed below. This does not enable or restore the V2 API.
The configuration in Neko V3 has been structured differently compared to V2. The V3 configuration is more modular and allows for more flexibility. The V3 configuration is split into multiple sections, each section is responsible for a specific part of the application. This allows for better organization and easier management of the configuration.
@@ -63,9 +55,7 @@ See the V3 configuration options for the [WebRTC Video](/docs/v3/configuration/c
| `NEKO_MAX_FPS` | **removed**, use [custom pipeline](/docs/v3/configuration/capture#video.gst_pipeline) instead |
:::warning Limitation
V2 did not have client-side cursor support, the cursor was always part of the video stream. In V3, the cursor is sent separately from the video stream. Therefore, when using legacy configuration, there will be two video streams created, one with the cursor (for V2 clients) and one without the cursor (for V3 clients). Please consider using new configuration options if this is not the desired behavior.
:::
V2 video settings map to a single V3 video pipeline. The V3 client renders the cursor separately from the video stream.
### WebRTC Audio {#config.audio}
@@ -109,7 +99,7 @@ See the V3 configuration options for the [Authentication](/docs/v3/configuration
| `NEKO_PASSWORD` | `NEKO_MEMBER_MULTIUSER_USER_PASSWORD` with `NEKO_MEMBER_PROVIDER=multiuser` |
| `NEKO_PASSWORD_ADMIN` | `NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD` with `NEKO_MEMBER_PROVIDER=multiuser` |
In order for the legacy authentication to work, you need to set [Multi-user](/docs/v3/configuration/authentication#member.multiuser).
The V2 password settings map to the [Multi-user](/docs/v3/configuration/authentication#member.multiuser) provider.
:::warning Limitation
V2 clients might not be compatible with any other authentication provider than the `multiuser`.
@@ -131,7 +121,7 @@ See the V3 configuration options for the [WebRTC](/docs/v3/configuration/webrtc)
### Full V2 Configuration Reference {#config.full}
Here is a full list of all the configuration options available in Neko V2 that are still available in Neko V3 with legacy support enabled.
Here is a full list of V2 configuration options that remain available as deprecated aliases in Neko V3.
import { ConfigurationTab } from '@site/src/components/Configuration';
import configOptions from './help.json';
@@ -142,95 +132,10 @@ See the full [V3 configuration reference](/docs/v3/configuration/#full) for more
## API {#api}
V3 is compatible with the V2 API when legacy support is enabled. There was specifically created a compatibility layer (legacy API) that allows V2 clients to connect to V3. The legacy API is enabled by default, but it can be disabled if needed. In later versions, the legacy API will be removed.
### Authentication {#api.auth}
In V2 there was only one authentication provider available, as in V3 called the [`multiuser`](/docs/v3/configuration/authentication#member.multiuser) provider. The API knew based on the provided password (as `?pwd=` query string) if the user is an admin or not.
Since V3 handles authentication differently (see [API documentation](/docs/v3/api#authentication)), there has been added `?usr=` query string to the API to specify the username. The password is still provided as `?pwd=` query string. The `?usr=` query string is still optional, if not provided, the API will generate a random username.
:::warning Limitation
For every request in the legacy API, a new user session is created based on the `?usr=&pwd=` query string. The session is destroyed after the API request is completed. So for HTTP API requests, the sessions are short-lived but for WebSocket API requests, the session is kept alive until the WebSocket connection is closed.
:::
Only the [`multiuser`](/docs/v3/configuration/authentication#member.multiuser) provider (or the [`noauth`](/docs/v3/configuration/authentication#member.noauth) provider) is supported without specifying the `?usr=` query string.
### WebSocket Messages {#api.ws}
Since WebSocket messages are not user-facing API, there exists no migration guide for them. When the legacy API is enabled, the user connects to the `/ws` endpoint and is handled by the compatibility layer V2 API. The V3 API is available at the `/api/ws` endpoint.
V2 used to send WebSocket ping messages every 60 seconds, whereas V3 sends them every 10 seconds and additionally uses a heartbeat mechanism to verify if the connection is still active.
### WebRTC API {#api.webrtc}
Since the WebRTC API is not user-facing API, there exists no migration guide for it. It has been changed to Big Endian format (previously Little Endian) to allow easier manipulation on the client side.
V2 created a new data channel on the client side, V3 creates a new data channel on the server side. That means, the server just listens for a new data channel from the client and accepts it with the legacy API handler. It overwrites the existing V3 data channel with the legacy one.
### HTTP API {#api.http}
The V2 version had a very limited HTTP API, the V3 API is much more powerful and flexible. See the [API documentation](/docs/v3/api) for more details.
#### GET `/health` {#api.http.health}
Migrated to the [Health](/docs/v3/api/healthcheck) endpoint for server health checks.
Returns `200 OK` if the server is running.
#### GET `/stats` {#api.http.stats}
Migrated to the [Stats](/docs/v3/api/stats) endpoint for server statistics and the [List Sessions](/docs/v3/api/sessions-get) endpoint for the session list.
Returns a JSON object with the following structure:
```json
{
// How many connections are currently active
"connections": 0,
// Who is currently having a session (empty if no one)
"host": "<session_id>",
// List of currently connected users
"members": [
{
"session_id": "<session_id>",
"displayname": "Name",
"admin": true,
"muted": false,
}
],
// List of banned IPs and who banned them as a session_id
"banned": {
"<ip>": "<session_id>"
},
// List of locked resources and who locked them as a session_id
"locked": {
"<resource>": "<session_id>"
},
// Server uptime
"server_started_at": "2021-01-01T00:00:00Z",
// When was the last admin or user left the session
"last_admin_left_at": "2021-01-01T00:00:00Z",
"last_user_left_at": "2021-01-01T00:00:00Z",
// Whether the control protection or implicit control is enabled
"control_protection": false,
"implicit_control": false,
}
```
#### GET `/screenshot.jpg` {#api.http.screenshot}
Migrated to the [Screenshot](/docs/v3/api/screen-shot-image) endpoint for taking screenshots.
Returns a screenshot of the desktop as a JPEG image.
#### GET `/file` {#api.http.file}
The whole functionality of file transfer has been moved to a [File Transfer Plugin](/docs/v3/configuration/plugins#filetransfer).
The V2 API compatibility layer has been removed. Migrate clients to the [V3 API](/docs/v3/api); the V2 `/ws` endpoint and V2 HTTP endpoints are no longer available.
## Limitations {#limitations}
In v2, locks and muted users were managed using a simple map that tracked who set the lock and what was locked. In v3, locks are now implemented as setting options and no longer store the `session_id` of the user who applied the lock. As a result, if a client refreshes the page or reconnects, the lock information is lost, and the user who set the lock is displayed as `Somebody`.
Additionally, when using the legacy API with a v2 client, API calls occur in a different order than expected. The client first retrieves the session list before registering the user, meaning the current `session_id` is not known when the session list is fetched. That means, the current user appears as `Somebody` in the session list.
Currently, the v3 has no native support for pipeline generation, meaning that the user has to manually specify the pipeline for video and audio if he wants to customize it. The v2 had a simple built-in support for setting the video bitrate, fps, audio bitrate, and hardware encoding. Since the introduction of multiple video pipelines in v3, this feature has been removed.
-8
View File
@@ -1,12 +1,4 @@
[
{
"key": [
"legacy"
],
"type": "boolean",
"defaultValue": "true",
"description": "enable legacy mode"
},
{
"key": [
"logs"
-2
View File
@@ -1,6 +1,4 @@
--legacy enable legacy mode (default true)
--logs save logs to file
--cert string path to the SSL cert used to secure the neko server
+21 -21
View File
@@ -1,29 +1,29 @@
# Release Notes
## master {#master}
## [n.eko v3.2.0](https://github.com/m1k1o/neko/releases/tag/v3.2.0) {#v3.2.0}
### New Features {#master-feats}
- Rewrote the web client in React with a framework-free core library (`client/core`, published as `@m1k1o/neko`) that uses only the v3 API, so it works with `NEKO_LEGACY=false`. The look and features of the Vue 2 client are kept: chat with markdown, emoji and open-in-app links, emotes, file transfer, member admin actions, locks, broadcast, microphone, all translations and URL parameters.
- Kick now removes the session (the user may log in again).
- The client reconnects by itself when its websocket goes silent (dropped networks often never close it), and asks for a new media stream when the server drops it.
- Ctrl+V pastes what was copied most recently on either side: local clipboard text is sent to the remote first when it is newer than the remote's. Text entered through an IME or an on-screen keyboard is typed as key events.
### New Features {#v3.2.0-feats}
- Replaced the Vue 2 web client with a React 19 and TypeScript client built with Vite. It keeps the familiar layout and workflows: login and invite links, OAuth, video controls, fullscreen and Picture-in-Picture, clipboard, control handoff, microphone, emotes, chat, file transfer, settings, broadcast, member actions, and all 15 translations and URL parameters ([#713](https://github.com/m1k1o/neko/pull/713)).
- Added a framework-free TypeScript client core, `client/core` (`@m1k1o/neko`), with authentication, room state and events, a pluggable stream transport, WebRTC, typed input channels, and the keyboard/mouse/touch overlay. It can be used independently of the React GUI or embedded in another framework ([#713](https://github.com/m1k1o/neko/pull/713)).
- The client detects silent websocket connections, reconnects with backoff, and requests a replacement media peer when WebRTC fails. The stream can continue through a signalling outage, and playback resumes after reconnection ([#713](https://github.com/m1k1o/neko/pull/713)).
- Added binary WebRTC data-channel input with websocket control-event fallback for transports without their own input channel. The client also handles IME and on-screen keyboard text as key events; Ctrl+V uses whichever clipboard was changed most recently ([#713](https://github.com/m1k1o/neko/pull/713)).
- Chat now renders CommonMark and GitHub Flavored Markdown, including tables, lists, and nested quotes. Links are limited to HTTP, HTTPS, and mailto; HTML and images are not rendered. The new renderer replaces the hand-written parser and keeps hostile or oversized messages from stalling the chat ([#713](https://github.com/m1k1o/neko/pull/713)).
### Fixes {#master-fixes}
- Fixed video timestamps running slow when the encoder falls behind: frames captured late or dropped before the encoder made the stream's clock lag real time, so browsers buffered up to seconds of video and lost lip sync. RTP timestamps now follow the time between captured frames ([#712](https://github.com/m1k1o/neko/pull/712)).
### Fixes {#v3.2.0-fixes}
- Bound each OAuth authorization state to a short-lived, HttpOnly browser cookie, preventing a callback URL from creating a session in a browser that did not start the login. Concurrent login flows and explicit reverse-proxy redirects are supported ([#708](https://github.com/m1k1o/neko/pull/708)).
- Fixed session and member IDs containing URL path characters. The `multiuser` and `noauth` providers now use sanitized IDs while preserving the display name; unsafe usernames submitted through `POST /api/members` are rejected ([#712](https://github.com/m1k1o/neko/pull/712)).
- Fixed a server panic when video pipelines were configured without explicit video IDs, and when no video stream was available ([#711](https://github.com/m1k1o/neko/pull/711)).
- Fixed slow WebRTC video timestamps under load, which could make browser buffers grow and audio/video drift. Generated pipelines also keep only the newest raw frame when an encoder falls behind, reducing input latency instead of accumulating stale frames ([#712](https://github.com/m1k1o/neko/pull/712)).
- Fixed the client leaving a user at the disconnected screen when the server rejects a banned session, and hiding the microphone control when the browser does not support media capture ([#713](https://github.com/m1k1o/neko/pull/713)).
- Internal websocket connections without an `Origin` header are now accepted, allowing trusted server-side websocket dials ([#710](https://github.com/m1k1o/neko/pull/710)).
### Changes {#master-changes}
- Generated video pipelines keep at most one raw frame queued, so a slow encoder drops frames instead of adding latency.
- New metric `neko_capture_streamsink_frames`: video frames produced per pipeline; a rate below the configured fps means the encoder falls behind.
- Session ids on the `multiuser` and `noauth` providers are built from a sanitised login name (the name itself is shown as typed); `POST /api/members` rejects usernames that would not be safe in a URL path.
- Chat messages are limited to `chat.max_length` characters (default 512, the web client's input limit); longer ones are rejected over the API and dropped over the websocket.
- The default video pipeline no longer draws the mouse pointer into the stream; the client draws it, so it was shown twice (once with the stream's delay). Set `capture.video.show_pointer` to `true` to get the old behaviour.
- The client no longer uses the legacy (v2) API. IP bans, which only existed in the legacy layer, are not available.
- Scroll sensitivity uses the v3 scale (-5 to 5); the `?scroll` query param is mapped onto it.
- File downloads are streamed by the browser instead of being buffered in memory.
- The microphone switches itself off when the connection is re-established.
- Opening the client in a second tab or window takes over the session; the first tab says so.
- Chat markdown follows CommonMark/GFM (headings, lists, tables, nested quotes); the Discord-style `>>>` block quote is gone, images are not rendered, and messages longer than 512 characters are shown as plain text.
- The client uses the system font stack instead of Whitney, and shows emoji as native characters instead of an image sheet.
### Changes {#v3.2.0-changes}
- The React client uses the v3 API only and works with `NEKO_LEGACY=false`. It no longer supports legacy v2-only behavior, including IP bans; kick removes the session, and the user may log in again ([#713](https://github.com/m1k1o/neko/pull/713)).
- Added `chat.max_length` (default 512 characters; `0` disables the limit). Longer messages receive HTTP 400 through REST and are dropped over the websocket. Incoming websocket frames are limited to 1 MiB ([#712](https://github.com/m1k1o/neko/pull/712)).
- Added the `neko_capture_streamsink_frames` metric to show when a video pipeline produces fewer frames than its configured frame rate ([#712](https://github.com/m1k1o/neko/pull/712)).
- The default video pipeline no longer draws the mouse pointer into the stream; the client draws it without the stream delay. Set `capture.video.show_pointer` to `true` to restore the old behavior ([#713](https://github.com/m1k1o/neko/pull/713)).
- Scroll sensitivity uses the v3 scale (-5 to 5), with the `?scroll` URL parameter mapped to it. File downloads are streamed instead of buffered in memory. The microphone turns off when the connection is re-established, and opening a second tab takes over the session ([#713](https://github.com/m1k1o/neko/pull/713)).
- The client uses the system font stack instead of Whitney and renders emoji as native characters rather than a sprite sheet ([#713](https://github.com/m1k1o/neko/pull/713)).
## [n.eko v3.1.6](https://github.com/m1k1o/neko/releases/tag/v3.1.6) {#v3.1.6}
+2 -1
View File
@@ -79,7 +79,8 @@ Bear in mind that your Neko server does not have to run on the same computer as
RewriteEngine on
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule /ws(.*) "ws://localhost:8080/ws$1" [P,L]
# forward every websocket upgrade to the same path (the client connects to /api/ws)
RewriteRule ^/(.*) "ws://localhost:8080/$1" [P,L]
# Available log levels: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
+6 -1
View File
@@ -28,7 +28,12 @@ export default async function createConfig(): Promise<Config> {
trailingSlash: false,
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},
i18n: {
defaultLocale: 'en',
+4659 -6521
View File
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -18,21 +18,21 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@docusaurus/core": "3.10.2",
"@docusaurus/preset-classic": "3.10.2",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"docusaurus-plugin-openapi-docs": "^4.3.4",
"docusaurus-theme-openapi-docs": "^4.3.4",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"remark-code-import": "^1.2.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.7.0",
"@docusaurus/tsconfig": "3.7.0",
"@docusaurus/types": "3.7.0",
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"typescript": "~5.6.2"
},
"browserslist": {
@@ -49,5 +49,13 @@
},
"engines": {
"node": ">=18.0"
},
"overrides": {
"js-yaml": "^4.3.2",
"postcss-selector-parser": "^7.1.6",
"serialize-javascript": "^7.1.2",
"tinypool": "^2.2.0",
"uuid": "^14.0.3",
"yaml": "^2.9.1"
}
}