Merge remote-tracking branch 'demodesk-neko/master' into v3-phase1

This commit is contained in:
Miroslav Šedivý 2024-09-06 18:55:13 +02:00
commit 3e1def9041
207 changed files with 77712 additions and 4 deletions

148
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,148 @@
#
# Stage 0: Build xorg dependencies.
#
FROM debian:bullseye-slim as xorg-deps
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y \
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
&& rm -rf /var/lib/apt/lists/*;
WORKDIR /xorg
COPY xorg/ /xorg/
# build xserver-xorg-video-dummy 0.3.8-2 with RandR support.
RUN set -eux; \
cd xf86-video-dummy; \
git clone --depth 1 --branch xserver-xorg-video-dummy-1_0.3.8-2 https://salsa.debian.org/xorg-team/driver/xserver-xorg-video-dummy; \
cd xserver-xorg-video-dummy; \
patch -p1 < ../xdummy-randr.patch; \
./autogen.sh; \
make -j$(nproc); \
make install;
# build custom input driver
RUN set -eux; \
cd xf86-input-neko; \
./autogen.sh --prefix=/usr; \
./configure; \
make -j$(nproc); \
make install;
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.166.0/containers/go/.devcontainer/base.Dockerfile
# [Choice] Go version: 1, 1.16, 1.15
ARG VARIANT="1"
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
# [Option] Install Node.js
ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# build dependencies
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
# install libxcvt-dev (not available in base image)
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt-dev_0.1.2-1_amd64.deb; \
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb ./libxcvt-dev_0.1.2-1_amd64.deb;
# runtime dependencies
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
wget ca-certificates supervisor \
pulseaudio dbus-x11 xserver-xorg-video-dummy \
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6 \
#
# needed for profile upload preStop hook
zip curl \
#
# file chooser handler, clipboard, drop
xdotool xclip libgtk-3-0 \
#
# gst
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
gstreamer1.0-pulseaudio;
# libxcvt already installed
# dev runtime dependencies
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
xfce4 xfce4-terminal firefox-esr sudo;
# configure runtime
ARG USERNAME=neko
ARG USER_UID=1001
ARG USER_GID=$USER_UID
RUN set -eux; \
#
# create a non-root user
groupadd --gid $USER_GID $USERNAME; \
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
adduser $USERNAME audio; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# add sudo support
echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME; \
chmod 0440 /etc/sudoers.d/$USERNAME; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# install fonts
apt-get install -y --no-install-recommends \
# Emojis
fonts-noto-color-emoji \
# Chinese fonts
fonts-arphic-ukai fonts-arphic-uming \
# Japanese fonts
fonts-ipafont-mincho fonts-ipafont-gothic \
# Korean fonts
fonts-unfonts-core \
# Indian fonts
fonts-indic;
# copy dependencies from previous stage
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
# copy runtime files
COPY runtime/dbus /usr/bin/dbus
COPY runtime/default.pa /etc/pulse/default.pa
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
COPY runtime/xorg.conf /etc/neko/xorg.conf
COPY runtime/icon-theme /home/$USERNAME/.icons/default
# copy dev runtime files
COPY dev/runtime/config.yml /etc/neko/neko.yml
COPY dev/runtime/supervisord.conf /etc/neko/supervisord/dev.conf
# customized scripts
RUN chmod +x /usr/bin/dbus;\
echo '#!/bin/sh\nsleep infinity' > /usr/bin/neko; \
chmod +x /usr/bin/neko; \
echo '#!/bin/sh\nsudo sh -c "export USER='$USERNAME'\nexport HOME=/home/'$USERNAME'\n/usr/bin/supervisord -c /etc/neko/supervisord.conf"' > /usr/bin/deps; \
chmod +x /usr/bin/deps; \
touch .env.development;
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV NEKO_SERVER_BIND=:3000
ENV NEKO_WEBRTC_EPR=3001-3004

20
.devcontainer/README.md Normal file
View file

@ -0,0 +1,20 @@
# dev container
You need to run all dependencies with `deps` command before you start debugging.
Create `.env.development` in repository root. Make sure your local IP is correct.
```sh
NEKO_WEBRTC_NAT1TO1=10.0.0.8
```
# without container
- Make sure `pulseaudio` contains correct configuration.
- Specify `DISPLAY` that is being used by xorg.
```sh
DISPLAY=:0
NEKO_WEBRTC_NAT1TO1=10.0.0.8
NEKO_SERVER_BIND=:3000
```

View file

@ -0,0 +1,44 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.166.0/containers/go
{
"name": "Go",
"build": {
"dockerfile": "Dockerfile",
"context": "../",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.15
"VARIANT": "1.20",
// Options
"INSTALL_NODE": "false",
"NODE_VERSION": "lts/*"
}
},
"runArgs": [ "--cap-add=SYS_PTRACE", "--cap-add=SYS_ADMIN", "--shm-size=2G", "--security-opt", "seccomp=unconfined" ],
"customizations": {
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go"
]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"appPort": ["3000:3000", "3001:3001/udp", "3002:3002/udp", "3003:3003/udp", "3004:3004/udp"],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "go version",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "neko"
}

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

2
.gitattributes vendored
View file

@ -19,4 +19,4 @@
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.mp3 binary

44
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Create and publish a Docker image
on:
push:
branches:
- 'master'
tags:
- 'v*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Log in to the Container registry
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

53
.github/workflows/build_variants.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Create and publish a Docker image variant
on:
push:
tags:
- 'v*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image-variant:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- variant: bookworm
dockerfile: Dockerfile.bookworm
- variant: nvidia
dockerfile: Dockerfile.nvidia
- variant: nvidia_bookworm
dockerfile: Dockerfile.nvidia.bookworm
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Log in to the Container registry
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ matrix.variant }}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

21
.github/workflows/pull_requests.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Build a Docker image
on:
pull_request:
branches:
- master
jobs:
build-image:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Build Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .

17
.gitignore vendored
View file

@ -33,3 +33,20 @@ bin
# Environment files
*.env
#
# Neko files
#
bin/
.idea
.env.development
runtime/fonts/*
!runtime/fonts/.gitkeep
runtime/icon-theme/*
!runtime/icon-theme/.gitkeep
plugins/*
!plugins/.gitkeep

20
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "launch",
"type": "go",
"debugAdapter": "dlv-dap",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/neko",
"output": "${workspaceFolder}/bin/debug/neko",
"cwd": "${workspaceFolder}/",
"args": ["serve", "-d", "-c", "dev/runtime/config.yml"],
"envFile": "${workspaceFolder}/.env.development"
}
]
}

15
.vscode/settings.json vendored
View file

@ -1 +1,14 @@
{}
{
"go.formatTool": "goformat",
"go.inferGopath": false,
"go.autocompleteUnimportedPackages": true,
"go.delveConfig": {
"useApiV1": false
},
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
}

182
Dockerfile Normal file
View file

@ -0,0 +1,182 @@
ARG BASE_IMAGE=debian:bullseye-slim
ARG BUILD_IMAGE=golang:1.21-bullseye
#
# Stage 0: Build xorg dependencies.
#
FROM $BASE_IMAGE as xorg-deps
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y \
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
&& rm -rf /var/lib/apt/lists/*;
WORKDIR /xorg
COPY xorg/ /xorg/
# build xf86-video-dummy v0.3.8 with RandR support
RUN set -eux; \
cd xf86-video-dummy/v0.3.8; \
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
autoreconf -v --install; \
./configure; \
make -j$(nproc); \
make install;
# build custom input driver
RUN set -eux; \
cd xf86-input-neko; \
./autogen.sh --prefix=/usr; \
./configure; \
make -j$(nproc); \
make install;
#
# Stage 1: Build.
#
FROM $BUILD_IMAGE as build
WORKDIR /src
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
# install libxcvt-dev (not available in debian:bullseye)
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt-dev_0.1.2-1_amd64.deb; \
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb ./libxcvt-dev_0.1.2-1_amd64.deb; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
ARG GIT_COMMIT
ARG GIT_BRANCH
ARG GIT_TAG
#
# build server
COPY . .
RUN ./build
#
# Stage 2: Runtime.
#
FROM $BASE_IMAGE as runtime
#
# set custom user
ARG USERNAME=neko
ARG USER_UID=1000
ARG USER_GID=$USER_UID
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
wget ca-certificates supervisor \
pulseaudio dbus-x11 xserver-xorg-video-dummy \
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6 \
#
# needed for profile upload preStop hook
zip curl \
#
# file chooser handler, clipboard, drop
xdotool xclip libgtk-3-0 \
#
# gst
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
gstreamer1.0-pulseaudio; \
# install libxcvt0 (not available in debian:bullseye)
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb; \
rm ./libxcvt0_0.1.2-1_amd64.deb; \
#
# create a non-root user
groupadd --gid $USER_GID $USERNAME; \
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
adduser $USERNAME audio; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# install fonts
apt-get install -y --no-install-recommends \
# Emojis
fonts-noto-color-emoji \
# Chinese fonts
fonts-arphic-ukai fonts-arphic-uming \
# Japanese fonts
fonts-ipafont-mincho fonts-ipafont-gothic \
# Korean fonts
fonts-unfonts-core \
# Indian fonts
fonts-indic; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
# copy dependencies from previous stage
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
#
# copy runtime configs
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
COPY runtime/dbus /usr/bin/dbus
COPY runtime/default.pa /etc/pulse/default.pa
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
COPY runtime/supervisord.dbus.conf /etc/neko/supervisord.dbus.conf
COPY runtime/xorg.conf /etc/neko/xorg.conf
#
# copy runtime folders
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
COPY runtime/fontconfig/* /etc/fonts/conf.d/
COPY runtime/fonts /usr/local/share/fonts
#
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV NEKO_SERVER_BIND=:8080
ENV NEKO_PLUGINS_ENABLED=true
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
#
# copy plugins from previous stage
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
#
# copy executable from previous stage
COPY --from=build /src/bin/neko /usr/bin/neko
#
# add healthcheck
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
#
# run neko
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]

172
Dockerfile.bookworm Normal file
View file

@ -0,0 +1,172 @@
ARG BASE_IMAGE=debian:bookworm-slim
ARG BUILD_IMAGE=golang:1.21-bookworm
#
# Stage 0: Build xorg dependencies.
#
FROM $BASE_IMAGE as xorg-deps
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y \
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
&& rm -rf /var/lib/apt/lists/*;
WORKDIR /xorg
COPY xorg/ /xorg/
# build xf86-video-dummy v0.3.8 with RandR support
RUN set -eux; \
cd xf86-video-dummy/v0.3.8; \
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
autoreconf -v --install; \
./configure; \
make -j$(nproc); \
make install;
# build custom input driver
RUN set -eux; \
cd xf86-input-neko; \
./autogen.sh --prefix=/usr; \
./configure; \
make -j$(nproc); \
make install;
#
# Stage 1: Build.
#
FROM $BUILD_IMAGE as build
WORKDIR /src
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev libxcvt-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
ARG GIT_COMMIT
ARG GIT_BRANCH
ARG GIT_TAG
#
# build server
COPY . .
RUN ./build
#
# Stage 2: Runtime.
#
FROM $BASE_IMAGE as runtime
#
# set custom user
ARG USERNAME=neko
ARG USER_UID=1000
ARG USER_GID=$USER_UID
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
wget ca-certificates supervisor \
pulseaudio xserver-xorg-video-dummy \
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx7 libxcvt0 \
#
# needed for profile upload preStop hook
zip curl \
#
# file chooser handler, clipboard, drop
xdotool xclip libgtk-3-0 \
#
# gst
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
gstreamer1.0-pulseaudio; \
#
# create a non-root user
groupadd --gid $USER_GID $USERNAME; \
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
adduser $USERNAME audio; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# install fonts
apt-get install -y --no-install-recommends \
# Emojis
fonts-noto-color-emoji \
# Chinese fonts
fonts-arphic-ukai fonts-arphic-uming \
# Japanese fonts
fonts-ipafont-mincho fonts-ipafont-gothic \
# Korean fonts
fonts-unfonts-core \
# Indian fonts
fonts-indic; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
# copy dependencies from previous stage
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
#
# copy runtime configs
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
COPY runtime/default.pa /etc/pulse/default.pa
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
COPY runtime/xorg.conf /etc/neko/xorg.conf
#
# copy runtime folders
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
COPY runtime/fontconfig/* /etc/fonts/conf.d/
COPY runtime/fonts /usr/local/share/fonts
#
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV NEKO_SERVER_BIND=:8080
ENV NEKO_PLUGINS_ENABLED=true
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
#
# copy plugins from previous stage
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
#
# copy executable from previous stage
COPY --from=build /src/bin/neko /usr/bin/neko
#
# add healthcheck
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
#
# run neko
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]

335
Dockerfile.nvidia Normal file
View file

@ -0,0 +1,335 @@
ARG UBUNTU_RELEASE=20.04
ARG CUDA_VERSION=11.4.3
ARG VIRTUALGL_VERSION=3.1
ARG GSTREAMER_VERSION=1.20
#
# Stage 0: Build gstreamer with nvidia plugins.
#
FROM ubuntu:${UBUNTU_RELEASE} AS gstreamer
ARG GSTREAMER_VERSION
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
# Install essentials
curl build-essential ca-certificates git \
# Install pip and ninja
python3-pip python-gi-dev ninja-build \
# Install build deps
autopoint autoconf automake autotools-dev libtool gettext bison flex gtk-doc-tools \
# Install libraries
librtmp-dev \
libvo-aacenc-dev \
libtool-bin \
libgtk2.0-dev \
libgl1-mesa-dev \
libopus-dev \
libpulse-dev \
libssl-dev \
libx264-dev \
libvpx-dev; \
# Install meson
pip3 install meson; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# build gstreamer
RUN set -eux; \
git clone --depth 1 --branch $GSTREAMER_VERSION https://gitlab.freedesktop.org/gstreamer/gstreamer.git /gstreamer/src; \
cd /gstreamer/src; \
mkdir -p /usr/share/gstreamer; \
meson --prefix /usr/share/gstreamer \
-Dgpl=enabled \
-Dugly=enabled \
-Dgst-plugins-ugly:x264=enabled \
build; \
ninja -C build; \
meson install -C build;
#
# Stage 0: Build xorg dependencies.
#
FROM debian:bullseye-slim as xorg-deps
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y \
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
&& rm -rf /var/lib/apt/lists/*;
WORKDIR /xorg
COPY xorg/ /xorg/
# build xf86-video-dummy v0.3.8 with RandR support
RUN set -eux; \
cd xf86-video-dummy/v0.3.8; \
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
autoreconf -v --install; \
./configure; \
make -j$(nproc); \
make install;
# build custom input driver
RUN set -eux; \
cd xf86-input-neko; \
./autogen.sh --prefix=/usr; \
./configure; \
make -j$(nproc); \
make install;
#
# Stage 1: Build.
#
FROM golang:1.21-bullseye as build
WORKDIR /src
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
# install libxcvt-dev (not available in debian:bullseye)
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt-dev_0.1.2-1_amd64.deb; \
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb ./libxcvt-dev_0.1.2-1_amd64.deb; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
ARG GIT_COMMIT
ARG GIT_BRANCH
ARG GIT_TAG
#
# build server
COPY . .
RUN ./build
#
# Stage 2: Runtime.
#
FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_RELEASE} as runtime
ARG UBUNTU_RELEASE
ARG VIRTUALGL_VERSION
# Make all NVIDIA GPUs visible by default
ENV NVIDIA_VISIBLE_DEVICES all
# All NVIDIA driver capabilities should preferably be used, check `NVIDIA_DRIVER_CAPABILITIES` inside the container if things do not work
ENV NVIDIA_DRIVER_CAPABILITIES all
#
# set vgl-display to headless 3d gpu card/// correct values are egl[n] or /dev/dri/card0:if this is passed into container
ENV VGL_DISPLAY egl
#
# set custom user
ARG USERNAME=neko
ARG USER_UID=1000
ARG USER_GID=$USER_UID
#
# install hardware accleration dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
dpkg --add-architecture i386; \
apt-get update; \
apt-get install -y --no-install-recommends \
# opengl base: https://gitlab.com/nvidia/container-images/opengl/-/blob/ubuntu20.04/base/Dockerfile
libxau6 libxau6:i386 \
libxdmcp6 libxdmcp6:i386 \
libxcb1 libxcb1:i386 \
libxext6 libxext6:i386 \
libx11-6 libx11-6:i386 \
# opengl runtime: https://gitlab.com/nvidia/container-images/opengl/-/blob/ubuntu20.04/glvnd/runtime/Dockerfile
libglvnd0 libglvnd0:i386 \
libgl1 libgl1:i386 \
libglx0 libglx0:i386 \
libegl1 libegl1:i386 \
libgles2 libgles2:i386 \
# hardware accleration utilities
libglu1 libglu1:i386 \
libvulkan-dev libvulkan-dev:i386 \
mesa-utils mesa-utils-extra \
mesa-va-drivers mesa-vulkan-drivers \
vainfo vdpauinfo; \
#
# install vulkan-utils or vulkan-tools depending on ubuntu release
if [ "${UBUNTU_RELEASE}" = "18.04" ]; then \
apt-get install -y --no-install-recommends vulkan-utils; \
else \
apt-get install -y --no-install-recommends vulkan-tools; \
fi; \
#
# create symlink for libnvrtc.so (needed for cudaconvert)
find /usr/local/cuda/lib64/ -maxdepth 1 -type l -name "*libnvrtc.so.*" -exec sh -c 'ln -sf {} /usr/local/cuda/lib64/libnvrtc.so' \;; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# add cuda to ld path, for gstreamer cuda plugins
ENV LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:/usr/lib/i386-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}:/usr/local/cuda/lib:/usr/local/cuda/lib64"
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
wget ca-certificates supervisor \
pulseaudio dbus-x11 xserver-xorg-video-dummy \
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx6 libx264-155 libvo-aacenc0 librtmp1 \
libgtk-3-bin software-properties-common cabextract aptitude vim curl \
#
# needed for profile upload preStop hook
zip curl \
#
# file chooser handler, clipboard, drop
xdotool xclip libgtk-3-0; \
# install libxcvt0 (not available in debian:bullseye)
wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_amd64.deb; \
apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_amd64.deb; \
rm ./libxcvt0_0.1.2-1_amd64.deb; \
#
# create a non-root user
groupadd --gid $USER_GID $USERNAME; \
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
adduser $USERNAME audio; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# install fonts
apt-get install -y --no-install-recommends \
# Emojis
fonts-noto-color-emoji \
# Chinese fonts
fonts-arphic-ukai fonts-arphic-uming \
# Japanese fonts
fonts-ipafont-mincho fonts-ipafont-gothic \
# Korean fonts
fonts-unfonts-core \
# Indian fonts
fonts-indic; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
# copy dependencies from previous stage
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
#
# configure EGL and Vulkan manually
RUN VULKAN_API_VERSION=$(dpkg -s libvulkan1 | grep -oP 'Version: [0-9|\.]+' | grep -oP '[0-9]+(\.[0-9]+)(\.[0-9]+)') && \
# Configure EGL manually
mkdir -p /usr/share/glvnd/egl_vendor.d/ && \
echo "{\n\
\"file_format_version\" : \"1.0.0\",\n\
\"ICD\": {\n\
\"library_path\": \"libEGL_nvidia.so.0\"\n\
}\n\
}" > /usr/share/glvnd/egl_vendor.d/10_nvidia.json && \
# Configure Vulkan manually
mkdir -p /etc/vulkan/icd.d/ && \
echo "{\n\
\"file_format_version\" : \"1.0.0\",\n\
\"ICD\": {\n\
\"library_path\": \"libGLX_nvidia.so.0\",\n\
\"api_version\" : \"${VULKAN_API_VERSION}\"\n\
}\n\
}" > /etc/vulkan/icd.d/nvidia_icd.json
#
# install VirtualGL and make libraries available for preload
RUN set -eux; \
apt-get update; \
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl_${VIRTUALGL_VERSION}_amd64.deb"; \
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
apt-get install -y --no-install-recommends ./virtualgl_${VIRTUALGL_VERSION}_amd64.deb ./virtualgl32_${VIRTUALGL_VERSION}_amd64.deb; \
rm -f "virtualgl_${VIRTUALGL_VERSION}_amd64.deb" "virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
chmod u+s /usr/lib/libvglfaker.so; \
chmod u+s /usr/lib/libdlfaker.so; \
chmod u+s /usr/lib32/libvglfaker.so; \
chmod u+s /usr/lib32/libdlfaker.so; \
chmod u+s /usr/lib/i386-linux-gnu/libvglfaker.so; \
chmod u+s /usr/lib/i386-linux-gnu/libdlfaker.so; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*;
#
# copy runtime configs
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
COPY runtime/dbus /usr/bin/dbus
COPY runtime/default.pa /etc/pulse/default.pa
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
COPY runtime/supervisord.dbus.conf /etc/neko/supervisord.dbus.conf
COPY runtime/xorg.conf /etc/neko/xorg.conf
#
# copy runtime folders
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
COPY runtime/fontconfig/* /etc/fonts/conf.d/
COPY runtime/fonts /usr/local/share/fonts
#
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV NEKO_SERVER_BIND=:8080
ENV NEKO_PLUGINS_ENABLED=true
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
#
# set gstreamer envs
ENV PATH="/usr/share/gstreamer/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
ENV PKG_CONFIG_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"
#
# copy gstreamer from previous stage
COPY --from=gstreamer /usr/share/gstreamer /usr/share/gstreamer
#
# copy plugins from previous stage
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
#
# copy executable from previous stage
COPY --from=build /src/bin/neko /usr/bin/neko
#
# add healthcheck
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
#
# run neko
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]

325
Dockerfile.nvidia.bookworm Normal file
View file

@ -0,0 +1,325 @@
ARG UBUNTU_RELEASE=22.04
ARG CUDA_VERSION=12.2.0
ARG VIRTUALGL_VERSION=3.1
ARG GSTREAMER_VERSION=1.22
#
# Stage 0: Build gstreamer with nvidia plugins.
#
FROM ubuntu:${UBUNTU_RELEASE} AS gstreamer
ARG GSTREAMER_VERSION
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
# Install essentials
curl build-essential ca-certificates git \
# Install pip and ninja
python3-pip python-gi-dev ninja-build \
# Install build deps
autopoint autoconf automake autotools-dev libtool gettext bison flex gtk-doc-tools \
# Install libraries
librtmp-dev \
libvo-aacenc-dev \
libtool-bin \
libgtk2.0-dev \
libgl1-mesa-dev \
libopus-dev \
libpulse-dev \
libssl-dev \
libx264-dev \
libvpx-dev; \
# Install meson
pip3 install meson; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# build gstreamer
RUN set -eux; \
git clone --depth 1 --branch $GSTREAMER_VERSION https://gitlab.freedesktop.org/gstreamer/gstreamer.git /gstreamer/src; \
cd /gstreamer/src; \
mkdir -p /usr/share/gstreamer; \
meson --prefix /usr/share/gstreamer \
-Dgpl=enabled \
-Dugly=enabled \
-Dgst-plugins-ugly:x264=enabled \
build; \
ninja -C build; \
meson install -C build;
#
# Stage 0: Build xorg dependencies.
#
FROM debian:bookworm-slim as xorg-deps
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y \
git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \
&& rm -rf /var/lib/apt/lists/*;
WORKDIR /xorg
COPY xorg/ /xorg/
# build xf86-video-dummy v0.3.8 with RandR support
RUN set -eux; \
cd xf86-video-dummy/v0.3.8; \
patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \
autoreconf -v --install; \
./configure; \
make -j$(nproc); \
make install;
# build custom input driver
RUN set -eux; \
cd xf86-input-neko; \
./autogen.sh --prefix=/usr; \
./configure; \
make -j$(nproc); \
make install;
#
# Stage 1: Build.
#
FROM golang:1.21-bookworm as build
WORKDIR /src
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libx11-dev libxrandr-dev libxtst-dev libgtk-3-dev libxcvt-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
ARG GIT_COMMIT
ARG GIT_BRANCH
ARG GIT_TAG
#
# build server
COPY . .
RUN ./build
#
# Stage 2: Runtime.
#
FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_RELEASE} as runtime
ARG UBUNTU_RELEASE
ARG VIRTUALGL_VERSION
# Make all NVIDIA GPUs visible by default
ENV NVIDIA_VISIBLE_DEVICES all
# All NVIDIA driver capabilities should preferably be used, check `NVIDIA_DRIVER_CAPABILITIES` inside the container if things do not work
ENV NVIDIA_DRIVER_CAPABILITIES all
#
# set vgl-display to headless 3d gpu card/// correct values are egl[n] or /dev/dri/card0:if this is passed into container
ENV VGL_DISPLAY egl
#
# set custom user
ARG USERNAME=neko
ARG USER_UID=1000
ARG USER_GID=$USER_UID
#
# install hardware accleration dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
dpkg --add-architecture i386; \
apt-get update; \
apt-get install -y --no-install-recommends \
# opengl base: https://gitlab.com/nvidia/container-images/opengl/-/blob/ubuntu20.04/base/Dockerfile
libxau6 libxau6:i386 \
libxdmcp6 libxdmcp6:i386 \
libxcb1 libxcb1:i386 \
libxext6 libxext6:i386 \
libx11-6 libx11-6:i386 \
# opengl runtime: https://gitlab.com/nvidia/container-images/opengl/-/blob/ubuntu20.04/glvnd/runtime/Dockerfile
libglvnd0 libglvnd0:i386 \
libgl1 libgl1:i386 \
libglx0 libglx0:i386 \
libegl1 libegl1:i386 \
libgles2 libgles2:i386 \
# hardware accleration utilities
libglu1 libglu1:i386 \
libvulkan-dev libvulkan-dev:i386 \
mesa-utils mesa-utils-extra \
mesa-va-drivers mesa-vulkan-drivers \
vainfo vdpauinfo; \
#
# install vulkan-utils or vulkan-tools depending on ubuntu release
if [ "${UBUNTU_RELEASE}" = "18.04" ]; then \
apt-get install -y --no-install-recommends vulkan-utils; \
else \
apt-get install -y --no-install-recommends vulkan-tools; \
fi; \
#
# create symlink for libnvrtc.so (needed for cudaconvert)
find /usr/local/cuda/lib64/ -maxdepth 1 -type l -name "*libnvrtc.so.*" -exec sh -c 'ln -sf {} /usr/local/cuda/lib64/libnvrtc.so' \;; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# add cuda to ld path, for gstreamer cuda plugins
ENV LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:/usr/lib/i386-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}:/usr/local/cuda/lib:/usr/local/cuda/lib64"
#
# install dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
wget ca-certificates supervisor \
pulseaudio xserver-xorg-video-dummy \
libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx7 libx264-163 libvo-aacenc0 librtmp1 libxcvt0 \
libgtk-3-bin software-properties-common cabextract aptitude vim curl \
#
# needed for profile upload preStop hook
zip curl \
#
# file chooser handler, clipboard, drop
xdotool xclip libgtk-3-0; \
#
# create a non-root user
groupadd --gid $USER_GID $USERNAME; \
useradd --uid $USER_UID --gid $USERNAME --shell /bin/bash --create-home $USERNAME; \
adduser $USERNAME audio; \
adduser $USERNAME video; \
adduser $USERNAME pulse; \
#
# workaround for an X11 problem: http://blog.tigerteufel.de/?p=476
mkdir /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown $USERNAME /tmp/.X11-unix/; \
#
# make directories for neko
mkdir -p /etc/neko /var/www; \
chown -R $USERNAME:$USERNAME /home/$USERNAME; \
#
# install fonts
apt-get install -y --no-install-recommends \
# Emojis
fonts-noto-color-emoji \
# Chinese fonts
fonts-arphic-ukai fonts-arphic-uming \
# Japanese fonts
fonts-ipafont-mincho fonts-ipafont-gothic \
# Korean fonts
fonts-unfonts-core \
# Indian fonts
fonts-indic; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
# copy dependencies from previous stage
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
#
# configure EGL and Vulkan manually
RUN VULKAN_API_VERSION=$(dpkg -s libvulkan1 | grep -oP 'Version: [0-9|\.]+' | grep -oP '[0-9]+(\.[0-9]+)(\.[0-9]+)') && \
# Configure EGL manually
mkdir -p /usr/share/glvnd/egl_vendor.d/ && \
echo "{\n\
\"file_format_version\" : \"1.0.0\",\n\
\"ICD\": {\n\
\"library_path\": \"libEGL_nvidia.so.0\"\n\
}\n\
}" > /usr/share/glvnd/egl_vendor.d/10_nvidia.json && \
# Configure Vulkan manually
mkdir -p /etc/vulkan/icd.d/ && \
echo "{\n\
\"file_format_version\" : \"1.0.0\",\n\
\"ICD\": {\n\
\"library_path\": \"libGLX_nvidia.so.0\",\n\
\"api_version\" : \"${VULKAN_API_VERSION}\"\n\
}\n\
}" > /etc/vulkan/icd.d/nvidia_icd.json
#
# install VirtualGL and make libraries available for preload
RUN set -eux; \
apt-get update; \
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl_${VIRTUALGL_VERSION}_amd64.deb"; \
wget "https://sourceforge.net/projects/virtualgl/files/virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
apt-get install -y --no-install-recommends ./virtualgl_${VIRTUALGL_VERSION}_amd64.deb ./virtualgl32_${VIRTUALGL_VERSION}_amd64.deb; \
rm -f "virtualgl_${VIRTUALGL_VERSION}_amd64.deb" "virtualgl32_${VIRTUALGL_VERSION}_amd64.deb"; \
chmod u+s /usr/lib/libvglfaker.so; \
chmod u+s /usr/lib/libdlfaker.so; \
chmod u+s /usr/lib32/libvglfaker.so; \
chmod u+s /usr/lib32/libdlfaker.so; \
chmod u+s /usr/lib/i386-linux-gnu/libvglfaker.so; \
chmod u+s /usr/lib/i386-linux-gnu/libdlfaker.so; \
#
# clean up
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*;
#
# copy runtime configs
COPY --chown=neko:neko runtime/.Xresources /home/$USERNAME/.Xresources
COPY runtime/default.pa /etc/pulse/default.pa
COPY runtime/supervisord.conf /etc/neko/supervisord.conf
COPY runtime/xorg.conf /etc/neko/xorg.conf
#
# copy runtime folders
COPY --chown=neko:neko runtime/icon-theme /home/$USERNAME/.icons/default
COPY runtime/fontconfig/* /etc/fonts/conf.d/
COPY runtime/fonts /usr/local/share/fonts
#
# set default envs
ENV USER=$USERNAME
ENV DISPLAY=:99.0
ENV PULSE_SERVER=unix:/tmp/pulseaudio.socket
ENV NEKO_SERVER_BIND=:8080
ENV NEKO_PLUGINS_ENABLED=true
ENV NEKO_PLUGINS_DIR=/etc/neko/plugins/
#
# set gstreamer envs
ENV PATH="/usr/share/gstreamer/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
ENV PKG_CONFIG_PATH="/usr/share/gstreamer/lib/x86_64-linux-gnu/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"
#
# copy gstreamer from previous stage
COPY --from=gstreamer /usr/share/gstreamer /usr/share/gstreamer
#
# copy plugins from previous stage
COPY --from=build /src/bin/plugins/ $NEKO_PLUGINS_DIR
#
# copy executable from previous stage
COPY --from=build /src/bin/neko /usr/bin/neko
#
# add healthcheck
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
CMD wget -O - http://localhost:${NEKO_SERVER_BIND#*:}/health || exit 1
#
# run neko
CMD ["/usr/bin/supervisord", "-s", "-c", "/etc/neko/supervisord.conf"]

View file

@ -187,7 +187,8 @@
identification within third-party archives.
Copyright (C) 2020 Nurdism <nurdism.io@gmail.com>
Copyright (C) 2020-2023 m1k1o
Copyright (C) 2021-2023 m1k1o & Demodesk GmbH
Copyright (C) 2024- m1k1o
All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
@ -200,4 +201,4 @@
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
limitations under the License.

87
build Executable file
View file

@ -0,0 +1,87 @@
#!/bin/bash
#
# aborting if any command returns a non-zero value
set -e
#
# do not build plugins when passing "core" as first argument
if [ "$1" = "core" ];
then
skip_plugins="true"
fi
#
# set git build variables if git exists
if git status > /dev/null 2>&1 && [ -z $GIT_COMMIT ] && [ -z $GIT_BRANCH ] && [ -z $GIT_TAG ];
then
GIT_COMMIT=`git rev-parse --short HEAD`
GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD`
GIT_TAG=`git tag --points-at $GIT_COMMIT | head -n 1`
fi
#
# load server dependencies
go get -v -t -d .
#
# build server
go build \
-o bin/neko \
-ldflags "
-s -w
-X 'github.com/demodesk/neko.buildDate=`date -u +'%Y-%m-%dT%H:%M:%SZ'`'
-X 'github.com/demodesk/neko.gitCommit=${GIT_COMMIT}'
-X 'github.com/demodesk/neko.gitBranch=${GIT_BRANCH}'
-X 'github.com/demodesk/neko.gitTag=${GIT_TAG}'
" \
cmd/neko/main.go;
#
# ensure plugins folder exists
mkdir -p bin/plugins
#
# if plugins are ignored
if [ "$skip_plugins" = "true" ];
then
echo "Not building plugins..."
exit 0
fi
#
# if plugins directory does not exist
if [ ! -d "./plugins" ];
then
echo "No plugins directory found, skipping..."
exit 0
fi
#
# remove old plugins
rm -f bin/plugins/*
#
# build plugins
for plugPath in ./plugins/*; do
if [ ! -d $plugPath ];
then
continue
fi
pushd $plugPath
echo "Building plugin: $plugPath"
if [ ! -f "go.plug.mod" ];
then
echo "go.plug.mod not found, skipping..."
popd
continue
fi
# build plugin
go build -modfile=go.plug.mod -buildmode=plugin -buildvcs=false -o "../../bin/plugins/${plugPath##*/}.so"
popd
done

18
cmd/neko/main.go Normal file
View file

@ -0,0 +1,18 @@
package main
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko"
"github.com/demodesk/neko/cmd"
"github.com/demodesk/neko/pkg/utils"
)
func main() {
fmt.Print(utils.Colorf(neko.Header, "server", neko.Version))
if err := cmd.Execute(); err != nil {
log.Panic().Err(err).Msg("failed to execute command")
}
}

49
cmd/plugins.go Normal file
View file

@ -0,0 +1,49 @@
package cmd
import (
"encoding/json"
"os"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/internal/plugins"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
func init() {
command := &cobra.Command{
Use: "plugins [directory]",
Short: "load, verify and list plugins",
Long: `load, verify and list plugins`,
Run: pluginsCmd,
Args: cobra.MaximumNArgs(1),
}
root.AddCommand(command)
}
func pluginsCmd(cmd *cobra.Command, args []string) {
pluginDir := "/etc/neko/plugins"
if len(args) > 0 {
pluginDir = args[0]
}
log.Info().Str("dir", pluginDir).Msg("plugins directory")
plugs := plugins.New(&config.Plugins{
Enabled: true,
Required: true,
Dir: pluginDir,
})
meta := plugs.Metadata()
if len(meta) == 0 {
log.Fatal().Msg("no plugins found")
}
// marshal indent to stdout
dec := json.NewEncoder(os.Stdout)
dec.SetIndent("", " ")
err := dec.Encode(meta)
if err != nil {
log.Fatal().Err(err).Msg("unable to marshal metadata")
}
}

165
cmd/root.go Normal file
View file

@ -0,0 +1,165 @@
package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/diode"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/demodesk/neko"
"github.com/demodesk/neko/internal/config"
)
func Execute() error {
// properly log unhandled panics
defer func() {
panicVal := recover()
if panicVal != nil {
log.Panic().Msgf("%v", panicVal)
}
}()
return root.Execute()
}
var root = &cobra.Command{
Use: "neko",
Short: "neko streaming server",
Long: `neko streaming server`,
Version: neko.Version.String(),
}
func init() {
rootConfig := config.Root{}
cobra.OnInitialize(func() {
//////
// configs
//////
config := viper.GetString("config") // Use config file from the flag.
if config == "" {
config = os.Getenv("NEKO_CONFIG") // Use config file from the environment variable.
}
if config != "" {
viper.SetConfigFile(config)
} else {
if runtime.GOOS == "linux" {
viper.AddConfigPath("/etc/neko/")
}
viper.AddConfigPath(".")
viper.SetConfigName("neko")
}
viper.SetEnvPrefix("NEKO")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv() // read in environment variables that match
// read config values
err := viper.ReadInConfig()
if err != nil {
_, notFound := err.(viper.ConfigFileNotFoundError)
if !notFound {
log.Fatal().Err(err).Msg("unable to read config file")
}
}
// get full config file path
config = viper.ConfigFileUsed()
// set root config values
rootConfig.Set()
//////
// logs
//////
var logWriter io.Writer
// log to a directory instead of stderr
if rootConfig.LogDir != "" {
if _, err := os.Stat(rootConfig.LogDir); os.IsNotExist(err) {
_ = os.Mkdir(rootConfig.LogDir, os.ModePerm)
}
latest := filepath.Join(rootConfig.LogDir, "neko-latest.log")
if _, err := os.Stat(latest); err == nil {
err = os.Rename(latest, filepath.Join(rootConfig.LogDir, "neko."+time.Now().Format("2006-01-02T15-04-05Z07-00")+".log"))
if err != nil {
log.Fatal().Err(err).Msg("failed to rotate log file")
}
}
logf, err := os.OpenFile(latest, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
log.Fatal().Err(err).Msg("failed to open log file")
}
logWriter = diode.NewWriter(logf, 1000, 10*time.Millisecond, func(missed int) {
fmt.Printf("logger dropped %d messages", missed)
})
} else {
logWriter = os.Stderr
}
// log console output instead of json
if !rootConfig.LogJson {
logWriter = zerolog.ConsoleWriter{
Out: logWriter,
NoColor: rootConfig.LogNocolor,
}
}
// save new logger output
log.Logger = log.Output(logWriter)
// set custom log level
if rootConfig.LogLevel != zerolog.NoLevel {
zerolog.SetGlobalLevel(rootConfig.LogLevel)
}
// set custom log tiem format
if rootConfig.LogTime != "" {
zerolog.TimeFieldFormat = rootConfig.LogTime
}
timeFormat := rootConfig.LogTime
if rootConfig.LogTime == zerolog.TimeFormatUnix {
timeFormat = "UNIX"
}
logger := log.With().
Str("config", config).
Str("log-level", zerolog.GlobalLevel().String()).
Bool("log-json", rootConfig.LogJson).
Str("log-time", timeFormat).
Str("log-dir", rootConfig.LogDir).
Logger()
if config == "" {
logger.Warn().Msg("preflight complete without config file")
} else {
if _, err := os.Stat(config); os.IsNotExist(err) {
logger.Err(err).Msg("preflight complete with nonexistent config file")
} else {
logger.Info().Msg("preflight complete with config file")
}
}
})
if err := rootConfig.Init(root); err != nil {
log.Panic().Err(err).Msg("unable to run root command")
}
root.SetVersionTemplate(neko.Version.Details())
}

212
cmd/serve.go Normal file
View file

@ -0,0 +1,212 @@
package cmd
import (
"os"
"os/signal"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/demodesk/neko/internal/api"
"github.com/demodesk/neko/internal/capture"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/internal/desktop"
"github.com/demodesk/neko/internal/http"
"github.com/demodesk/neko/internal/member"
"github.com/demodesk/neko/internal/plugins"
"github.com/demodesk/neko/internal/session"
"github.com/demodesk/neko/internal/webrtc"
"github.com/demodesk/neko/internal/websocket"
)
func init() {
service := serve{}
command := &cobra.Command{
Use: "serve",
Short: "serve neko streaming server",
Long: `serve neko streaming server`,
PreRun: service.PreRun,
Run: service.Run,
}
if err := service.Init(command); err != nil {
log.Panic().Err(err).Msg("unable to initialize configuration")
}
root.AddCommand(command)
}
type serve struct {
logger zerolog.Logger
configs struct {
Desktop config.Desktop
Capture config.Capture
WebRTC config.WebRTC
Member config.Member
Session config.Session
Plugins config.Plugins
Server config.Server
}
managers struct {
desktop *desktop.DesktopManagerCtx
capture *capture.CaptureManagerCtx
webRTC *webrtc.WebRTCManagerCtx
member *member.MemberManagerCtx
session *session.SessionManagerCtx
webSocket *websocket.WebSocketManagerCtx
plugins *plugins.ManagerCtx
api *api.ApiManagerCtx
http *http.HttpManagerCtx
}
}
func (c *serve) Init(cmd *cobra.Command) error {
if err := c.configs.Desktop.Init(cmd); err != nil {
return err
}
if err := c.configs.Capture.Init(cmd); err != nil {
return err
}
if err := c.configs.WebRTC.Init(cmd); err != nil {
return err
}
if err := c.configs.Member.Init(cmd); err != nil {
return err
}
if err := c.configs.Session.Init(cmd); err != nil {
return err
}
if err := c.configs.Plugins.Init(cmd); err != nil {
return err
}
if err := c.configs.Server.Init(cmd); err != nil {
return err
}
return nil
}
func (c *serve) PreRun(cmd *cobra.Command, args []string) {
c.logger = log.With().Str("service", "neko").Logger()
c.configs.Desktop.Set()
c.configs.Capture.Set()
c.configs.WebRTC.Set()
c.configs.Member.Set()
c.configs.Session.Set()
c.configs.Plugins.Set()
c.configs.Server.Set()
}
func (c *serve) Start(cmd *cobra.Command) {
c.managers.session = session.New(
&c.configs.Session,
)
c.managers.member = member.New(
c.managers.session,
&c.configs.Member,
)
if err := c.managers.member.Connect(); err != nil {
c.logger.Panic().Err(err).Msg("unable to connect to member manager")
}
c.managers.desktop = desktop.New(
&c.configs.Desktop,
)
c.managers.desktop.Start()
c.managers.capture = capture.New(
c.managers.desktop,
&c.configs.Capture,
)
c.managers.capture.Start()
c.managers.webRTC = webrtc.New(
c.managers.desktop,
c.managers.capture,
&c.configs.WebRTC,
)
c.managers.webRTC.Start()
c.managers.webSocket = websocket.New(
c.managers.session,
c.managers.desktop,
c.managers.capture,
c.managers.webRTC,
)
c.managers.webSocket.Start()
c.managers.api = api.New(
c.managers.session,
c.managers.member,
c.managers.desktop,
c.managers.capture,
)
c.managers.plugins = plugins.New(
&c.configs.Plugins,
)
// init and set configuration now
// this means it won't be in --help
c.managers.plugins.InitConfigs(cmd)
c.managers.plugins.SetConfigs()
c.managers.plugins.Start(
c.managers.session,
c.managers.webSocket,
c.managers.api,
)
c.managers.http = http.New(
c.managers.webSocket,
c.managers.api,
&c.configs.Server,
)
c.managers.http.Start()
}
func (c *serve) Shutdown() {
var err error
err = c.managers.http.Shutdown()
c.logger.Err(err).Msg("http manager shutdown")
err = c.managers.plugins.Shutdown()
c.logger.Err(err).Msg("plugins manager shutdown")
err = c.managers.webSocket.Shutdown()
c.logger.Err(err).Msg("websocket manager shutdown")
err = c.managers.webRTC.Shutdown()
c.logger.Err(err).Msg("webrtc manager shutdown")
err = c.managers.capture.Shutdown()
c.logger.Err(err).Msg("capture manager shutdown")
err = c.managers.desktop.Shutdown()
c.logger.Err(err).Msg("desktop manager shutdown")
err = c.managers.member.Disconnect()
c.logger.Err(err).Msg("member manager disconnect")
}
func (c *serve) Run(cmd *cobra.Command, args []string) {
c.logger.Info().Msg("starting neko server")
c.Start(cmd)
c.logger.Info().Msg("neko ready")
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
sig := <-quit
c.logger.Warn().Msgf("received %s, attempting graceful shutdown", sig)
c.Shutdown()
c.logger.Info().Msg("shutdown complete")
}

23
dev/build Executable file
View file

@ -0,0 +1,23 @@
#!/bin/bash
cd "$(dirname "$0")"
#
# aborting if any command returns a non-zero value
set -e
GIT_COMMIT=`git rev-parse --short HEAD`
GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD`
# if first argument is nvidia, use nvidia dockerfile
if [ "$1" = "nvidia" ]; then
echo "Building nvidia docker image"
DOCKERFILE="Dockerfile.nvidia"
else
echo "Building default docker image"
DOCKERFILE="Dockerfile"
fi
docker build -t neko_server_build --target build --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_BRANCH=$GIT_BRANCH" -f ../$DOCKERFILE ..
docker build -t neko_server_runtime --target runtime --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_BRANCH=$GIT_BRANCH" -f ../$DOCKERFILE ..
docker build -t neko_server_app --build-arg "BASE_IMAGE=neko_server_runtime" -f ./runtime/Dockerfile ./runtime

3
dev/exec Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
docker exec -it neko_server_dev /bin/bash

12
dev/fmt Executable file
View file

@ -0,0 +1,12 @@
#!/bin/bash
cd "$(dirname "$0")"
if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then
echo "Image 'neko_server_build' not found. Run ./build first."
exit 1
fi
docker run -it --rm \
--entrypoint="go" \
-v "${PWD}/../:/src" \
neko_server_build fmt ./...

25
dev/go Executable file
View file

@ -0,0 +1,25 @@
#!/bin/bash
cd "$(dirname "$0")"
if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then
echo "Image 'neko_server_build' not found. Run ./build first."
exit 1
fi
docker run -it \
--name "neko_server_go" \
--entrypoint="go" \
-v "${PWD}/../:/src" \
neko_server_build "$@";
#
# copy package files
docker cp neko_server_go:/src/go.mod "../go.mod"
docker cp neko_server_go:/src/go.sum "../go.sum"
#
# commit changes to image
docker commit "neko_server_go" "neko_server_build"
#
# remove contianer
docker rm "neko_server_go"

14
dev/lint Executable file
View file

@ -0,0 +1,14 @@
#!/bin/bash
cd "$(dirname "$0")"
if [ "$(docker images -q neko_server_build 2> /dev/null)" == "" ]; then
echo "Image 'neko_server_build' not found. Run ./build first."
exit 1
fi
#
# build server
docker run --rm -it \
-v "${PWD}/../:/src" \
--entrypoint="/bin/bash" \
neko_server_build -c '[ -f ./bin/golangci-lint ] || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.31.0;./bin/golangci-lint run';

32
dev/rebuild Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
cd "$(dirname "$0")"
#
# aborting if any command returns a non-zero value
set -e
#
# build server
docker run --rm -it \
-v "${PWD}/../:/src" \
--entrypoint="/bin/bash" \
neko_server_build "./build" "$@";
#
# remove old plugins
docker exec neko_server_dev rm -rf /etc/neko/plugins
#
# replace server binary in container
docker cp "${PWD}/../bin/neko" neko_server_dev:/usr/bin/neko
#
# replace plugin binaries in container
if [ -d "${PWD}/../bin/plugins" ];
then
docker cp "${PWD}/../bin/plugins" neko_server_dev:/etc/neko/plugins
fi
#
# restart server
docker exec neko_server_dev supervisorctl -c /etc/neko/supervisord.conf restart neko

32
dev/rebuild.input Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
cd "$(dirname "$0")"
cd ../xorg/xf86-input-neko
#
# aborting if any command returns a non-zero value
set -e
#
# check if docker image exists
if [ -z "$(docker images -q xf86-input-neko)" ]; then
echo "Docker image not found, building it"
docker build -t xf86-input-neko .
fi
#
# if there is no ./configure script, run autogen.sh and configure
if [ ! -f ./configure ]; then
docker run -v $PWD/:/app --rm xf86-input-neko bash -c './autogen.sh && ./configure'
fi
#
# make install
docker run -v $PWD/:/app --rm xf86-input-neko bash -c 'make && make install DESTDIR=/app/build'
#
# replace input driver in container
docker cp "${PWD}/build/usr/local/lib/xorg/modules/input/neko_drv.so" neko_server_dev:/usr/lib/xorg/modules/input/neko_drv.so
#
# restart server
docker exec neko_server_dev supervisorctl -c /etc/neko/supervisord.conf restart x-server

31
dev/runtime/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
ARG BASE_IMAGE=neko_server_runtime:latest
FROM $BASE_IMAGE
ARG SRC_URL="https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US"
#
# install xfce and firefox
RUN set -eux; apt-get update; \
apt-get install -y --no-install-recommends \
dbus-x11 xfce4 xfce4-terminal sudo \
xz-utils bzip2 libgtk-3-0 libdbus-glib-1-2; \
#
# fetch latest firefox release
wget -O /tmp/firefox-setup.tar.bz2 "${SRC_URL}"; \
mkdir /usr/lib/firefox; \
tar -xjf /tmp/firefox-setup.tar.bz2 -C /usr/lib; \
rm -f /tmp/firefox-setup.tar.bz2; \
ln -s /usr/lib/firefox/firefox /usr/bin/firefox; \
#
# add user to sudoers
usermod -aG sudo neko; \
echo "neko:neko" | chpasswd; \
echo "%sudo ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers; \
# clean up
apt-get --purge autoremove -y xz-utils bzip2; \
apt-get clean -y; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/*
#
# copy configuation files
COPY supervisord.conf /etc/neko/supervisord/xfce.conf

View file

@ -0,0 +1,101 @@
capture:
video:
codec: h264
ids:
- nvh264enc
- x264enc
pipelines:
nvh264enc:
fps: 25
bitrate: 2
#gst_prefix: "! cudaupload ! cudaconvert ! video/x-raw(memory:CUDAMemory),format=NV12"
gst_prefix: "! video/x-raw,format=NV12"
gst_encoder: "nvh264enc"
gst_params:
bitrate: 3000
rc-mode: 5 # Low-Delay CBR, High Quality
preset: 5 # Low Latency, High Performance
zerolatency: true
gop-size: 25
gst_suffix: "! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"
x264enc:
fps: 25
bitrate: 1
gst_prefix: "! video/x-raw,format=I420"
gst_encoder: "x264enc"
gst_params:
threads: 4
bitrate: 4096
key-int-max: 25
byte-stream: true
tune: zerolatency
speed-preset: veryfast
gst_suffix: "! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"
server:
pprof: true
desktop:
screen: "1920x1080@60"
member:
provider: "object"
object:
users:
- username: "admin"
password: "admin"
profile:
name: "Administrator"
is_admin: true
can_login: true
can_connect: true
can_watch: true
can_host: true
can_share_media: true
can_access_clipboard: true
sends_inactive_cursor: true
can_see_inactive_cursors: true
- username: "user"
password: "neko"
profile:
name: "User"
is_admin: false
can_login: true
can_connect: true
can_watch: true
can_host: true
can_share_media: true
can_access_clipboard: true
sends_inactive_cursor: true
can_see_inactive_cursors: false
# provider: "file"
# file:
# path: "/home/neko/members.json"
# provider: "multiuser"
# multiuser:
# admin_password: "admin"
# user_password: "neko"
# provider: "noauth"
session:
# Allows reconnecting the websocket even if the previous
# connection was not closed. Can lead to session hijacking.
merciful_reconnect: true
# Show inactive cursors on the screen. Can lead to multiple
# data sent via WebSockets and additonal rendering cost on
# the clients.
inactive_cursors: true
api_token: "neko123"
cookie:
# Disabling cookies will result to use Bearer Authentication.
# This is less secure, because access token will be sent to
# client in playload and accessible via JS app.
enabled: false
secure: false
webrtc:
icelite: true
iceservers:
- urls: [ stun:stun.l.google.com:19302 ]
# username: foo
# credential: bar

143
dev/runtime/config.yml Normal file
View file

@ -0,0 +1,143 @@
capture:
video:
codec: vp8
ids: [ hq, lq ]
pipelines:
hq:
fps: 25
gst_encoder: vp8enc
gst_params:
target-bitrate: round(3072 * 650)
cpu-used: 4
end-usage: cbr
threads: 4
deadline: 1
undershoot: 95
buffer-size: (3072 * 4)
buffer-initial-size: (3072 * 2)
buffer-optimal-size: (3072 * 3)
keyframe-max-dist: 25
min-quantizer: 4
max-quantizer: 20
lq:
fps: 25
gst_encoder: vp8enc
gst_params:
target-bitrate: round(1024 * 650)
cpu-used: 4
end-usage: cbr
threads: 4
deadline: 1
undershoot: 95
buffer-size: (1024 * 4)
buffer-initial-size: (1024 * 2)
buffer-optimal-size: (1024 * 3)
keyframe-max-dist: 25
min-quantizer: 4
max-quantizer: 20
# video:
# codec: h264
# ids: [ main ]
# pipelines:
# main:
# width: (width / 3) * 2
# height: (height / 3) * 2
# fps: 20
# gst_prefix: "! video/x-raw,format=I420"
# gst_encoder: "x264enc"
# gst_params:
# threads: 4
# bitrate: 4096
# key-int-max: 15
# byte-stream: true
# tune: zerolatency
# speed-preset: veryfast
# gst_suffix: "! video/x-h264,stream-format=byte-stream"
screencast:
enabled: true
server:
pprof: true
desktop:
screen: "1920x1080@60"
member:
provider: "object"
object:
users:
- username: "admin"
password: "admin"
profile:
name: "Administrator"
is_admin: true
can_login: true
can_connect: true
can_watch: true
can_host: true
can_share_media: true
can_access_clipboard: true
sends_inactive_cursor: true
can_see_inactive_cursors: true
- username: "user"
password: "neko"
profile:
name: "User"
is_admin: false
can_login: true
can_connect: true
can_watch: true
can_host: true
can_share_media: true
can_access_clipboard: true
sends_inactive_cursor: true
can_see_inactive_cursors: false
# provider: "file"
# file:
# path: "/home/neko/members.json"
# provider: "multiuser"
# multiuser:
# admin_password: "admin"
# user_password: "neko"
# admin_profile: # optional
# user_profile: # optional
# provider: "noauth"
session:
# Allows reconnecting the websocket even if the previous
# connection was not closed. Can lead to session hijacking.
merciful_reconnect: true
# Show inactive cursors on the screen. Can lead to multiple
# data sent via WebSockets and additonal rendering cost on
# the clients.
inactive_cursors: true
api_token: "neko123"
cookie:
# Disabling cookies will result to use Bearer Authentication.
# This is less secure, because access token will be sent to
# client in playload and accessible via JS app.
enabled: false
secure: false
webrtc:
icelite: true
iceservers:
# Backend servers are ignored if icelite is true.
backend:
- urls: [ stun:stun.l.google.com:19302 ]
frontend:
- urls: [ stun:stun.l.google.com:19305 ]
#username: foo
#credential: bar
# estimator:
# enabled: true
# passive: false
# debug: true
# initial_bitrate: 1000000
# read_interval: 1s
# stable_duration: 10s
# unstable_duration: 5s
# stalled_duration: 20s
# downgrade_backoff: 10s
# upgrade_backoff: 30s
# diff_threshold: 0.5

View file

@ -0,0 +1,10 @@
[program:xfce]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/startxfce4
stopsignal=INT
autorestart=true
priority=500
user=%(ENV_USER)s
stdout_logfile=/dev/stderr
stdout_logfile_maxbytes=0
redirect_stderr=true

60
dev/start Executable file
View file

@ -0,0 +1,60 @@
#!/bin/bash
cd "$(dirname "$0")"
if [ -z "$(docker images -q neko_server_app 2> /dev/null)" ]; then
echo "Image 'neko_server_app' not found. Running ./build first."
./build
fi
if [ -z $NEKO_PORT ]; then
NEKO_PORT="3000"
fi
if [ -z $NEKO_MUX ]; then
NEKO_MUX="52100"
fi
if [ -z $NEKO_NAT1TO1 ]; then
for i in $(ifconfig -l 2>/dev/null); do
NEKO_NAT1TO1=$(ipconfig getifaddr $i)
if [ ! -z $NEKO_NAT1TO1 ]; then
break
fi
done
if [ -z $NEKO_NAT1TO1 ]; then
NEKO_NAT1TO1=$(hostname -i 2>/dev/null)
fi
fi
# if first argument is nvidia, start with nvidia runtime
if [ "$1" = "nvidia" ]; then
echo "Starting nvidia docker image"
EXTRAOPTS="--gpus all"
CONFIG="config.nvidia.yml"
else
echo "Starting default docker image"
EXTRAOPTS=""
CONFIG="config.yml"
fi
echo "Using app port: ${NEKO_PORT}"
echo "Using mux port: ${NEKO_MUX}"
echo "Using IP address: ${NEKO_NAT1TO1}"
# start server
docker run --rm -it \
--name "neko_server_dev" \
-p "${NEKO_PORT}:8080" \
-p "${NEKO_MUX}:${NEKO_MUX}/tcp" \
-p "${NEKO_MUX}:${NEKO_MUX}/udp" \
-e "NEKO_WEBRTC_UDPMUX=${NEKO_MUX}" \
-e "NEKO_WEBRTC_TCPMUX=${NEKO_MUX}" \
-e "NEKO_WEBRTC_NAT1TO1=${NEKO_NAT1TO1}" \
-e "NEKO_SESSION_FILE=/home/neko/sessions.txt" \
-v "${PWD}/runtime/$CONFIG:/etc/neko/neko.yml" \
-e "NEKO_DEBUG=1" \
--shm-size=2G \
--security-opt seccomp=unconfined \
$EXTRAOPTS \
neko_server_app:latest;

68
go.mod Normal file
View file

@ -0,0 +1,68 @@
module github.com/demodesk/neko
go 1.21
require (
github.com/PaesslerAG/gval v1.2.2
github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1
github.com/gorilla/websocket v1.5.1
github.com/kataras/go-events v0.0.3
github.com/pion/ice/v2 v2.3.12
github.com/pion/interceptor v0.1.25
github.com/pion/logging v0.2.2
github.com/pion/rtcp v1.2.13
github.com/pion/webrtc/v3 v3.2.24
github.com/prometheus/client_golang v1.18.0
github.com/rs/zerolog v1.31.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pion/datachannel v1.5.5 // indirect
github.com/pion/dtls/v2 v2.2.9 // indirect
github.com/pion/mdns v0.0.9 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtp v1.8.3 // indirect
github.com/pion/sctp v1.8.9 // indirect
github.com/pion/sdp/v3 v3.0.6 // indirect
github.com/pion/srtp/v2 v2.0.18 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.4 // indirect
github.com/pion/turn/v2 v2.1.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.46.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

305
go.sum Normal file
View file

@ -0,0 +1,305 @@
github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E=
github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac=
github.com/PaesslerAG/jsonpath v0.1.0 h1:gADYeifvlqK3R3i2cR5B4DGgxLXIPb3TRTH1mGi0jPI=
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.9 h1:K+D/aVf9/REahQvqk6G5JavdrD8W1PWDKC11UlwN7ts=
github.com/pion/dtls/v2 v2.2.9/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/ice/v2 v2.3.11/go.mod h1:hPcLC3kxMa+JGRzMHqQzjoSj3xtE9F+eoncmXLlCL4E=
github.com/pion/ice/v2 v2.3.12 h1:NWKW2b3+oSZS3klbQMIEWQ0i52Kuo0KBg505a5kQv4s=
github.com/pion/ice/v2 v2.3.12/go.mod h1:hPcLC3kxMa+JGRzMHqQzjoSj3xtE9F+eoncmXLlCL4E=
github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc=
github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.8/go.mod h1:hYE72WX8WDveIhg7fmXgMKivD3Puklk0Ymzog0lSyaI=
github.com/pion/mdns v0.0.9 h1:7Ue5KZsqq8EuqStnpPWV33vYYEH0+skdDN5L7EiEsI4=
github.com/pion/mdns v0.0.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecIc=
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.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.13 h1:+EQijuisKwm/8VBs8nWllr0bIndR7Lf7cZG200mpbNo=
github.com/pion/rtcp v1.2.13/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
github.com/pion/sctp v1.8.8/go.mod h1:igF9nZBrjh5AtmKc7U30jXltsFHicFCXSmWA2GWRaWs=
github.com/pion/sctp v1.8.9 h1:TP5ZVxV5J7rz7uZmbyvnUvsn7EJ2x/5q9uhsTtXbI3g=
github.com/pion/sctp v1.8.9/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI=
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo=
github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40=
github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.2/go.mod h1:OJg3ojoBJopjEeECq2yJdXH9YVrUJ1uQ++NjXLOUorc=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v2 v2.1.4 h1:2xn8rduI5W6sCZQkEnIUDAkrBQNl2eYIBCHMZ3QMmP8=
github.com/pion/turn/v2 v2.1.4/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/webrtc/v3 v3.2.24 h1:MiFL5DMo2bDaaIFWr0DDpwiV/L4EGbLZb+xoRvfEo1Y=
github.com/pion/webrtc/v3 v3.2.24/go.mod h1:1CaT2fcZzZ6VZA+O1i9yK2DU4EOcXVvSbWG9pr5jefs=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
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/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,84 @@
package members
import (
"encoding/json"
"io"
"net/http"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type MemberBulkUpdatePayload struct {
IDs []string `json:"ids"`
Profile types.MemberProfile `json:"profile"`
}
func (h *MembersHandler) membersBulkUpdate(w http.ResponseWriter, r *http.Request) error {
bytes, err := io.ReadAll(r.Body)
if err != nil {
return utils.HttpBadRequest("unable to read post body").WithInternalErr(err)
}
header := &MemberBulkUpdatePayload{}
if err := json.Unmarshal(bytes, &header); err != nil {
return utils.HttpBadRequest("unable to unmarshal payload").WithInternalErr(err)
}
for _, memberId := range header.IDs {
// TODO: Bulk select?
profile, err := h.members.Select(memberId)
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to select member profile").
Msgf("failed to update member %s", memberId)
}
body := &MemberBulkUpdatePayload{
Profile: profile,
}
if err := json.Unmarshal(bytes, &body); err != nil {
return utils.HttpBadRequest().
WithInternalErr(err).
Msgf("unable to unmarshal payload for member %s", memberId)
}
if err := h.members.UpdateProfile(memberId, body.Profile); err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to update member profile").
Msgf("failed to update member %s", memberId)
}
}
return utils.HttpSuccess(w)
}
type MemberBulkDeletePayload struct {
IDs []string `json:"ids"`
}
func (h *MembersHandler) membersBulkDelete(w http.ResponseWriter, r *http.Request) error {
bytes, err := io.ReadAll(r.Body)
if err != nil {
return utils.HttpBadRequest("unable to read post body").WithInternalErr(err)
}
data := &MemberBulkDeletePayload{}
if err := json.Unmarshal(bytes, &data); err != nil {
return utils.HttpBadRequest("unable to unmarshal payload").WithInternalErr(err)
}
for _, memberId := range data.IDs {
if err := h.members.Delete(memberId); err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to delete member").
Msgf("failed to delete member %s", memberId)
}
}
return utils.HttpSuccess(w)
}

View file

@ -0,0 +1,144 @@
package members
import (
"errors"
"net/http"
"strconv"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type MemberDataPayload struct {
ID string `json:"id"`
Profile types.MemberProfile `json:"profile"`
}
type MemberCreatePayload struct {
Username string `json:"username"`
Password string `json:"password"`
Profile types.MemberProfile `json:"profile"`
}
type MemberPasswordPayload struct {
Password string `json:"password"`
}
func (h *MembersHandler) membersList(w http.ResponseWriter, r *http.Request) error {
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil {
// TODO: Default zero.
limit = 0
}
offset, err := strconv.Atoi(r.URL.Query().Get("offset"))
if err != nil {
// TODO: Default zero.
offset = 0
}
entries, err := h.members.SelectAll(limit, offset)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
members := []MemberDataPayload{}
for id, profile := range entries {
members = append(members, MemberDataPayload{
ID: id,
Profile: profile,
})
}
return utils.HttpSuccess(w, members)
}
func (h *MembersHandler) membersCreate(w http.ResponseWriter, r *http.Request) error {
data := &MemberCreatePayload{
// default values
Profile: types.MemberProfile{
IsAdmin: false,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: true,
},
}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
if data.Username == "" {
return utils.HttpBadRequest("username cannot be empty")
}
if data.Password == "" {
return utils.HttpBadRequest("password cannot be empty")
}
id, err := h.members.Insert(data.Username, data.Password, data.Profile)
if err != nil {
if errors.Is(err, types.ErrMemberAlreadyExists) {
return utils.HttpUnprocessableEntity("member already exists")
}
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w, MemberDataPayload{
ID: id,
Profile: data.Profile,
})
}
func (h *MembersHandler) membersRead(w http.ResponseWriter, r *http.Request) error {
member := GetMember(r)
profile := member.Profile
return utils.HttpSuccess(w, profile)
}
func (h *MembersHandler) membersUpdateProfile(w http.ResponseWriter, r *http.Request) error {
member := GetMember(r)
data := &member.Profile
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
if err := h.members.UpdateProfile(member.ID, *data); err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w)
}
func (h *MembersHandler) membersUpdatePassword(w http.ResponseWriter, r *http.Request) error {
member := GetMember(r)
data := &MemberPasswordPayload{}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
if err := h.members.UpdatePassword(member.ID, data.Password); err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w)
}
func (h *MembersHandler) membersDelete(w http.ResponseWriter, r *http.Request) error {
member := GetMember(r)
if err := h.members.Delete(member.ID); err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w)
}

View file

@ -0,0 +1,83 @@
package members
import (
"context"
"errors"
"net/http"
"github.com/go-chi/chi"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type key int
const keyMemberCtx key = iota
type MembersHandler struct {
members types.MemberManager
}
func New(
members types.MemberManager,
) *MembersHandler {
// Init
return &MembersHandler{
members: members,
}
}
func (h *MembersHandler) Route(r types.Router) {
r.Get("/", h.membersList)
r.With(auth.AdminsOnly).Group(func(r types.Router) {
r.Post("/", h.membersCreate)
r.With(h.ExtractMember).Route("/{memberId}", func(r types.Router) {
r.Get("/", h.membersRead)
r.Post("/", h.membersUpdateProfile)
r.Post("/password", h.membersUpdatePassword)
r.Delete("/", h.membersDelete)
})
})
}
func (h *MembersHandler) RouteBulk(r types.Router) {
r.With(auth.AdminsOnly).Group(func(r types.Router) {
r.Post("/update", h.membersBulkUpdate)
r.Post("/delete", h.membersBulkDelete)
})
}
type MemberData struct {
ID string
Profile types.MemberProfile
}
func SetMember(r *http.Request, session MemberData) context.Context {
return context.WithValue(r.Context(), keyMemberCtx, session)
}
func GetMember(r *http.Request) MemberData {
return r.Context().Value(keyMemberCtx).(MemberData)
}
func (h *MembersHandler) ExtractMember(w http.ResponseWriter, r *http.Request) (context.Context, error) {
memberId := chi.URLParam(r, "memberId")
profile, err := h.members.Select(memberId)
if err != nil {
if errors.Is(err, types.ErrMemberDoesNotExist) {
return nil, utils.HttpNotFound("member not found")
}
return nil, utils.HttpInternalServerError().WithInternalErr(err)
}
return SetMember(r, MemberData{
ID: memberId,
Profile: profile,
}), nil
}

View file

@ -0,0 +1,70 @@
package room
import (
"net/http"
"github.com/demodesk/neko/pkg/types/event"
"github.com/demodesk/neko/pkg/types/message"
"github.com/demodesk/neko/pkg/utils"
)
type BroadcastStatusPayload struct {
URL string `json:"url,omitempty"`
IsActive bool `json:"is_active"`
}
func (h *RoomHandler) broadcastStatus(w http.ResponseWriter, r *http.Request) error {
broadcast := h.capture.Broadcast()
return utils.HttpSuccess(w, BroadcastStatusPayload{
IsActive: broadcast.Started(),
URL: broadcast.Url(),
})
}
func (h *RoomHandler) boradcastStart(w http.ResponseWriter, r *http.Request) error {
data := &BroadcastStatusPayload{}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
if data.URL == "" {
return utils.HttpBadRequest("missing broadcast URL")
}
broadcast := h.capture.Broadcast()
if broadcast.Started() {
return utils.HttpUnprocessableEntity("server is already broadcasting")
}
if err := broadcast.Start(data.URL); err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
h.sessions.AdminBroadcast(
event.BORADCAST_STATUS,
message.BroadcastStatus{
IsActive: broadcast.Started(),
URL: broadcast.Url(),
})
return utils.HttpSuccess(w)
}
func (h *RoomHandler) boradcastStop(w http.ResponseWriter, r *http.Request) error {
broadcast := h.capture.Broadcast()
if !broadcast.Started() {
return utils.HttpUnprocessableEntity("server is not broadcasting")
}
broadcast.Stop()
h.sessions.AdminBroadcast(
event.BORADCAST_STATUS,
message.BroadcastStatus{
IsActive: broadcast.Started(),
URL: broadcast.Url(),
})
return utils.HttpSuccess(w)
}

View file

@ -0,0 +1,107 @@
package room
import (
// TODO: Unused now.
//"bytes"
//"strings"
"net/http"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type ClipboardPayload struct {
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
}
func (h *RoomHandler) clipboardGetText(w http.ResponseWriter, r *http.Request) error {
data, err := h.desktop.ClipboardGetText()
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w, ClipboardPayload{
Text: data.Text,
HTML: data.HTML,
})
}
func (h *RoomHandler) clipboardSetText(w http.ResponseWriter, r *http.Request) error {
data := &ClipboardPayload{}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
err := h.desktop.ClipboardSetText(types.ClipboardText{
Text: data.Text,
HTML: data.HTML,
})
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w)
}
func (h *RoomHandler) clipboardGetImage(w http.ResponseWriter, r *http.Request) error {
bytes, err := h.desktop.ClipboardGetBinary("image/png")
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "image/png")
_, err = w.Write(bytes)
return err
}
/* TODO: Unused now.
func (h *RoomHandler) clipboardSetImage(w http.ResponseWriter, r *http.Request) error {
err := r.ParseMultipartForm(MAX_UPLOAD_SIZE)
if err != nil {
return utils.HttpBadRequest("failed to parse multipart form").WithInternalErr(err)
}
//nolint
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
return utils.HttpBadRequest("no file received").WithInternalErr(err)
}
defer file.Close()
mime := header.Header.Get("Content-Type")
if !strings.HasPrefix(mime, "image/") {
return utils.HttpBadRequest("file must be image")
}
buffer := new(bytes.Buffer)
_, err = buffer.ReadFrom(file)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err).WithInternalMsg("unable to read from uploaded file")
}
err = h.desktop.ClipboardSetBinary("image/png", buffer.Bytes())
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err).WithInternalMsg("unable set image to clipboard")
}
return utils.HttpSuccess(w)
}
func (h *RoomHandler) clipboardGetTargets(w http.ResponseWriter, r *http.Request) error {
targets, err := h.desktop.ClipboardGetTargets()
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w, targets)
}
*/

View file

@ -0,0 +1,96 @@
package room
import (
"net/http"
"github.com/go-chi/chi"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/utils"
)
type ControlStatusPayload struct {
HasHost bool `json:"has_host"`
HostId string `json:"host_id,omitempty"`
}
type ControlTargetPayload struct {
ID string `json:"id"`
}
func (h *RoomHandler) controlStatus(w http.ResponseWriter, r *http.Request) error {
host, hasHost := h.sessions.GetHost()
var hostId string
if hasHost {
hostId = host.ID()
}
return utils.HttpSuccess(w, ControlStatusPayload{
HasHost: hasHost,
HostId: hostId,
})
}
func (h *RoomHandler) controlRequest(w http.ResponseWriter, r *http.Request) error {
_, hasHost := h.sessions.GetHost()
if hasHost {
return utils.HttpUnprocessableEntity("there is already a host")
}
session, _ := auth.GetSession(r)
if h.sessions.Settings().LockedControls && !session.Profile().IsAdmin {
return utils.HttpForbidden("controls are locked")
}
h.sessions.SetHost(session)
return utils.HttpSuccess(w)
}
func (h *RoomHandler) controlRelease(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
if !session.IsHost() {
return utils.HttpUnprocessableEntity("session is not the host")
}
h.desktop.ResetKeys()
h.sessions.ClearHost()
return utils.HttpSuccess(w)
}
func (h *RoomHandler) controlTake(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
h.sessions.SetHost(session)
return utils.HttpSuccess(w)
}
func (h *RoomHandler) controlGive(w http.ResponseWriter, r *http.Request) error {
sessionId := chi.URLParam(r, "sessionId")
target, ok := h.sessions.Get(sessionId)
if !ok {
return utils.HttpNotFound("target session was not found")
}
if !target.Profile().CanHost {
return utils.HttpBadRequest("target session is not allowed to host")
}
h.sessions.SetHost(target)
return utils.HttpSuccess(w)
}
func (h *RoomHandler) controlReset(w http.ResponseWriter, r *http.Request) error {
_, hasHost := h.sessions.GetHost()
if hasHost {
h.desktop.ResetKeys()
h.sessions.ClearHost()
}
return utils.HttpSuccess(w)
}

View file

@ -0,0 +1,126 @@
package room
import (
"context"
"net/http"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type RoomHandler struct {
sessions types.SessionManager
desktop types.DesktopManager
capture types.CaptureManager
privateModeImage []byte
}
func New(
sessions types.SessionManager,
desktop types.DesktopManager,
capture types.CaptureManager,
) *RoomHandler {
h := &RoomHandler{
sessions: sessions,
desktop: desktop,
capture: capture,
}
// generate fallback image for private mode when needed
sessions.OnSettingsChanged(func(new types.Settings, old types.Settings) {
if old.PrivateMode && !new.PrivateMode {
log.Debug().Msg("clearing private mode fallback image")
h.privateModeImage = nil
return
}
if !old.PrivateMode && new.PrivateMode {
img := h.desktop.GetScreenshotImage()
bytes, err := utils.CreateJPGImage(img, 90)
if err != nil {
log.Err(err).Msg("could not generate private mode fallback image")
return
}
log.Debug().Msg("using private mode fallback image")
h.privateModeImage = bytes
}
})
return h
}
func (h *RoomHandler) Route(r types.Router) {
r.With(auth.AdminsOnly).Route("/settings", func(r types.Router) {
r.Post("/", h.settingsSet)
r.Get("/", h.settingsGet)
})
r.With(auth.AdminsOnly).Route("/broadcast", func(r types.Router) {
r.Get("/", h.broadcastStatus)
r.Post("/start", h.boradcastStart)
r.Post("/stop", h.boradcastStop)
})
r.With(auth.CanAccessClipboardOnly).With(auth.HostsOnly).Route("/clipboard", func(r types.Router) {
r.Get("/", h.clipboardGetText)
r.Post("/", h.clipboardSetText)
r.Get("/image.png", h.clipboardGetImage)
// TODO: Refactor. xclip is failing to set propper target type
// and this content is sent back to client as text in another
// clipboard update. Therefore endpoint is not usable!
//r.Post("/image", h.clipboardSetImage)
// TODO: Refactor. If there would be implemented custom target
// retrieval, this endpoint would be useful.
//r.Get("/targets", h.clipboardGetTargets)
})
r.With(auth.CanHostOnly).Route("/keyboard", func(r types.Router) {
r.Get("/map", h.keyboardMapGet)
r.With(auth.HostsOnly).Post("/map", h.keyboardMapSet)
r.Get("/modifiers", h.keyboardModifiersGet)
r.With(auth.HostsOnly).Post("/modifiers", h.keyboardModifiersSet)
})
r.With(auth.CanHostOnly).Route("/control", func(r types.Router) {
r.Get("/", h.controlStatus)
r.Post("/request", h.controlRequest)
r.Post("/release", h.controlRelease)
r.With(auth.AdminsOnly).Post("/take", h.controlTake)
r.With(auth.AdminsOnly).Post("/give/{sessionId}", h.controlGive)
r.With(auth.AdminsOnly).Post("/reset", h.controlReset)
})
r.With(auth.CanWatchOnly).Route("/screen", func(r types.Router) {
r.Get("/", h.screenConfiguration)
r.With(auth.AdminsOnly).Post("/", h.screenConfigurationChange)
r.With(auth.AdminsOnly).Get("/configurations", h.screenConfigurationsList)
r.Get("/cast.jpg", h.screenCastGet)
r.With(auth.AdminsOnly).Get("/shot.jpg", h.screenShotGet)
})
r.With(h.uploadMiddleware).Route("/upload", func(r types.Router) {
r.Post("/drop", h.uploadDrop)
r.Post("/dialog", h.uploadDialogPost)
r.Delete("/dialog", h.uploadDialogClose)
})
}
func (h *RoomHandler) uploadMiddleware(w http.ResponseWriter, r *http.Request) (context.Context, error) {
session, ok := auth.GetSession(r)
if !ok || (!session.IsHost() && (!session.Profile().CanHost || !h.sessions.Settings().ImplicitHosting)) {
return nil, utils.HttpForbidden("without implicit hosting, only host can upload files")
}
return nil, nil
}

View file

@ -0,0 +1,57 @@
package room
import (
"net/http"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type KeyboardMapData struct {
types.KeyboardMap
}
type KeyboardModifiersData struct {
types.KeyboardModifiers
}
func (h *RoomHandler) keyboardMapSet(w http.ResponseWriter, r *http.Request) error {
data := &KeyboardMapData{}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
err := h.desktop.SetKeyboardMap(data.KeyboardMap)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w)
}
func (h *RoomHandler) keyboardMapGet(w http.ResponseWriter, r *http.Request) error {
data, err := h.desktop.GetKeyboardMap()
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w, KeyboardMapData{
KeyboardMap: *data,
})
}
func (h *RoomHandler) keyboardModifiersSet(w http.ResponseWriter, r *http.Request) error {
data := &KeyboardModifiersData{}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
h.desktop.SetKeyboardModifiers(data.KeyboardModifiers)
return utils.HttpSuccess(w)
}
func (h *RoomHandler) keyboardModifiersGet(w http.ResponseWriter, r *http.Request) error {
return utils.HttpSuccess(w, KeyboardModifiersData{
KeyboardModifiers: h.desktop.GetKeyboardModifiers(),
})
}

119
internal/api/room/screen.go Normal file
View file

@ -0,0 +1,119 @@
package room
import (
"net/http"
"strconv"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/event"
"github.com/demodesk/neko/pkg/types/message"
"github.com/demodesk/neko/pkg/utils"
)
type ScreenConfigurationPayload struct {
Width int `json:"width"`
Height int `json:"height"`
Rate int16 `json:"rate"`
}
func (h *RoomHandler) screenConfiguration(w http.ResponseWriter, r *http.Request) error {
size := h.desktop.GetScreenSize()
return utils.HttpSuccess(w, ScreenConfigurationPayload{
Width: size.Width,
Height: size.Height,
Rate: size.Rate,
})
}
func (h *RoomHandler) screenConfigurationChange(w http.ResponseWriter, r *http.Request) error {
data := &ScreenConfigurationPayload{}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
size, err := h.desktop.SetScreenSize(types.ScreenSize{
Width: data.Width,
Height: data.Height,
Rate: data.Rate,
})
if err != nil {
return utils.HttpUnprocessableEntity("cannot set screen size").WithInternalErr(err)
}
h.sessions.Broadcast(event.SCREEN_UPDATED, message.ScreenSize{
Width: size.Width,
Height: size.Height,
Rate: size.Rate,
})
return utils.HttpSuccess(w, data)
}
// TODO: remove.
func (h *RoomHandler) screenConfigurationsList(w http.ResponseWriter, r *http.Request) error {
configurations := h.desktop.ScreenConfigurations()
list := make([]ScreenConfigurationPayload, 0, len(configurations))
for _, conf := range configurations {
list = append(list, ScreenConfigurationPayload{
Width: conf.Width,
Height: conf.Height,
Rate: conf.Rate,
})
}
return utils.HttpSuccess(w, list)
}
func (h *RoomHandler) screenShotGet(w http.ResponseWriter, r *http.Request) error {
quality, err := strconv.Atoi(r.URL.Query().Get("quality"))
if err != nil {
quality = 90
}
img := h.desktop.GetScreenshotImage()
bytes, err := utils.CreateJPGImage(img, quality)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "image/jpeg")
_, err = w.Write(bytes)
return err
}
func (h *RoomHandler) screenCastGet(w http.ResponseWriter, r *http.Request) error {
// display fallback image when private mode is enabled even if screencast is not
if session, ok := auth.GetSession(r); ok && session.PrivateModeEnabled() {
if h.privateModeImage != nil {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "image/jpeg")
_, err := w.Write(h.privateModeImage)
return err
}
return utils.HttpBadRequest("private mode is enabled but no fallback image available")
}
screencast := h.capture.Screencast()
if !screencast.Enabled() {
return utils.HttpBadRequest("screencast pipeline is not enabled")
}
bytes, err := screencast.Image()
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "image/jpeg")
_, err = w.Write(bytes)
return err
}

View file

@ -0,0 +1,24 @@
package room
import (
"net/http"
"github.com/demodesk/neko/pkg/utils"
)
func (h *RoomHandler) settingsGet(w http.ResponseWriter, r *http.Request) error {
settings := h.sessions.Settings()
return utils.HttpSuccess(w, settings)
}
func (h *RoomHandler) settingsSet(w http.ResponseWriter, r *http.Request) error {
settings := h.sessions.Settings()
if err := utils.HttpJsonRequest(w, r, &settings); err != nil {
return err
}
h.sessions.UpdateSettings(settings)
return utils.HttpSuccess(w)
}

172
internal/api/room/upload.go Normal file
View file

@ -0,0 +1,172 @@
package room
import (
"io"
"net/http"
"os"
"path"
"strconv"
"github.com/demodesk/neko/pkg/utils"
)
// TODO: Extract file uploading to custom utility.
// maximum upload size of 32 MB
const maxUploadSize = 32 << 20
func (h *RoomHandler) uploadDrop(w http.ResponseWriter, r *http.Request) error {
if !h.desktop.IsUploadDropEnabled() {
return utils.HttpBadRequest("upload drop is disabled")
}
err := r.ParseMultipartForm(maxUploadSize)
if err != nil {
return utils.HttpBadRequest("failed to parse multipart form").WithInternalErr(err)
}
//nolint
defer r.MultipartForm.RemoveAll()
X, err := strconv.Atoi(r.FormValue("x"))
if err != nil {
return utils.HttpBadRequest("no X coordinate received").WithInternalErr(err)
}
Y, err := strconv.Atoi(r.FormValue("y"))
if err != nil {
return utils.HttpBadRequest("no Y coordinate received").WithInternalErr(err)
}
req_files := r.MultipartForm.File["files"]
if len(req_files) == 0 {
return utils.HttpBadRequest("no files received")
}
dir, err := os.MkdirTemp("", "neko-drop-*")
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to create temporary directory")
}
files := []string{}
for _, req_file := range req_files {
path := path.Join(dir, req_file.Filename)
srcFile, err := req_file.Open()
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to open uploaded file")
}
defer srcFile.Close()
dstFile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to open destination file")
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to copy uploaded file to destination file")
}
files = append(files, path)
}
if !h.desktop.DropFiles(X, Y, files) {
return utils.HttpInternalServerError().
WithInternalMsg("unable to drop files")
}
return utils.HttpSuccess(w)
}
func (h *RoomHandler) uploadDialogPost(w http.ResponseWriter, r *http.Request) error {
if !h.desktop.IsFileChooserDialogEnabled() {
return utils.HttpBadRequest("file chooser dialog is disabled")
}
err := r.ParseMultipartForm(maxUploadSize)
if err != nil {
return utils.HttpBadRequest("failed to parse multipart form").WithInternalErr(err)
}
//nolint
defer r.MultipartForm.RemoveAll()
req_files := r.MultipartForm.File["files"]
if len(req_files) == 0 {
return utils.HttpBadRequest("no files received")
}
if !h.desktop.IsFileChooserDialogOpened() {
return utils.HttpUnprocessableEntity("file chooser dialog is not open")
}
dir, err := os.MkdirTemp("", "neko-dialog-*")
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to create temporary directory")
}
for _, req_file := range req_files {
path := path.Join(dir, req_file.Filename)
srcFile, err := req_file.Open()
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to open uploaded file")
}
defer srcFile.Close()
dstFile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to open destination file")
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to copy uploaded file to destination file")
}
}
if err := h.desktop.HandleFileChooserDialog(dir); err != nil {
return utils.HttpInternalServerError().
WithInternalErr(err).
WithInternalMsg("unable to handle file chooser dialog")
}
return utils.HttpSuccess(w)
}
func (h *RoomHandler) uploadDialogClose(w http.ResponseWriter, r *http.Request) error {
if !h.desktop.IsFileChooserDialogEnabled() {
return utils.HttpBadRequest("file chooser dialog is disabled")
}
if !h.desktop.IsFileChooserDialogOpened() {
return utils.HttpUnprocessableEntity("file chooser dialog is not open")
}
h.desktop.CloseFileChooserDialog()
return utils.HttpSuccess(w)
}

82
internal/api/router.go Normal file
View file

@ -0,0 +1,82 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/demodesk/neko/internal/api/members"
"github.com/demodesk/neko/internal/api/room"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type ApiManagerCtx struct {
sessions types.SessionManager
members types.MemberManager
desktop types.DesktopManager
capture types.CaptureManager
routers map[string]func(types.Router)
}
func New(
sessions types.SessionManager,
members types.MemberManager,
desktop types.DesktopManager,
capture types.CaptureManager,
) *ApiManagerCtx {
return &ApiManagerCtx{
sessions: sessions,
members: members,
desktop: desktop,
capture: capture,
routers: make(map[string]func(types.Router)),
}
}
func (api *ApiManagerCtx) Route(r types.Router) {
r.Post("/login", api.Login)
// Authenticated area
r.Group(func(r types.Router) {
r.Use(api.Authenticate)
r.Post("/logout", api.Logout)
r.Get("/whoami", api.Whoami)
r.Get("/sessions", api.Sessions)
membersHandler := members.New(api.members)
r.Route("/members", membersHandler.Route)
r.Route("/members_bulk", membersHandler.RouteBulk)
roomHandler := room.New(api.sessions, api.desktop, api.capture)
r.Route("/room", roomHandler.Route)
for path, router := range api.routers {
r.Route(path, router)
}
})
}
func (api *ApiManagerCtx) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
session, err := api.sessions.Authenticate(r)
if err != nil {
if api.sessions.CookieEnabled() {
api.sessions.CookieClearToken(w, r)
}
if errors.Is(err, types.ErrSessionLoginDisabled) {
return nil, utils.HttpForbidden("login is disabled for this session")
}
return nil, utils.HttpUnauthorized().WithInternalErr(err)
}
return auth.SetSession(r, session), nil
}
func (api *ApiManagerCtx) AddRouter(path string, router func(types.Router)) {
api.routers[path] = router
}

96
internal/api/session.go Normal file
View file

@ -0,0 +1,96 @@
package api
import (
"errors"
"net/http"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type SessionLoginPayload struct {
Username string `json:"username"`
Password string `json:"password"`
}
type SessionDataPayload struct {
ID string `json:"id"`
Token string `json:"token,omitempty"`
Profile types.MemberProfile `json:"profile"`
State types.SessionState `json:"state"`
}
func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error {
data := &SessionLoginPayload{}
if err := utils.HttpJsonRequest(w, r, data); err != nil {
return err
}
session, token, err := api.members.Login(data.Username, data.Password)
if err != nil {
if errors.Is(err, types.ErrSessionAlreadyConnected) {
return utils.HttpUnprocessableEntity("session already connected")
} else if errors.Is(err, types.ErrMemberDoesNotExist) || errors.Is(err, types.ErrMemberInvalidPassword) {
return utils.HttpUnauthorized().WithInternalErr(err)
} else {
return utils.HttpInternalServerError().WithInternalErr(err)
}
}
sessionData := SessionDataPayload{
ID: session.ID(),
Profile: session.Profile(),
State: session.State(),
}
if api.sessions.CookieEnabled() {
api.sessions.CookieSetToken(w, token)
} else {
sessionData.Token = token
}
return utils.HttpSuccess(w, sessionData)
}
func (api *ApiManagerCtx) Logout(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
err := api.members.Logout(session.ID())
if err != nil {
if errors.Is(err, types.ErrSessionNotFound) {
return utils.HttpBadRequest("session is not logged in")
} else {
return utils.HttpInternalServerError().WithInternalErr(err)
}
}
if api.sessions.CookieEnabled() {
api.sessions.CookieClearToken(w, r)
}
return utils.HttpSuccess(w, true)
}
func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
return utils.HttpSuccess(w, SessionDataPayload{
ID: session.ID(),
Profile: session.Profile(),
State: session.State(),
})
}
func (api *ApiManagerCtx) Sessions(w http.ResponseWriter, r *http.Request) error {
sessions := []SessionDataPayload{}
for _, session := range api.sessions.List() {
sessions = append(sessions, SessionDataPayload{
ID: session.ID(),
Profile: session.Profile(),
State: session.State(),
})
}
return utils.HttpSuccess(w, sessions)
}

View file

@ -0,0 +1,156 @@
package capture
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/pkg/gst"
"github.com/demodesk/neko/pkg/types"
)
type BroacastManagerCtx struct {
logger zerolog.Logger
mu sync.Mutex
pipeline gst.Pipeline
pipelineMu sync.Mutex
pipelineFn func(url string) (string, error)
url string
started bool
// metrics
pipelinesCounter prometheus.Counter
pipelinesActive prometheus.Gauge
}
func broadcastNew(pipelineFn func(url string) (string, error), defaultUrl string) *BroacastManagerCtx {
logger := log.With().
Str("module", "capture").
Str("submodule", "broadcast").
Logger()
return &BroacastManagerCtx{
logger: logger,
pipelineFn: pipelineFn,
url: defaultUrl,
started: defaultUrl != "",
// metrics
pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{
Name: "pipelines_total",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of created pipelines.",
ConstLabels: map[string]string{
"submodule": "broadcast",
"video_id": "main",
"codec_name": "-",
"codec_type": "-",
},
}),
pipelinesActive: promauto.NewGauge(prometheus.GaugeOpts{
Name: "pipelines_active",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of active pipelines.",
ConstLabels: map[string]string{
"submodule": "broadcast",
"video_id": "main",
"codec_name": "-",
"codec_type": "-",
},
}),
}
}
func (manager *BroacastManagerCtx) shutdown() {
manager.logger.Info().Msgf("shutdown")
manager.destroyPipeline()
}
func (manager *BroacastManagerCtx) Start(url string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
err := manager.createPipeline()
if err != nil {
return err
}
manager.url = url
manager.started = true
return nil
}
func (manager *BroacastManagerCtx) Stop() {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.started = false
manager.destroyPipeline()
}
func (manager *BroacastManagerCtx) Started() bool {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.started
}
func (manager *BroacastManagerCtx) Url() string {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.url
}
func (manager *BroacastManagerCtx) createPipeline() error {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline != nil {
return types.ErrCapturePipelineAlreadyExists
}
pipelineStr, err := manager.pipelineFn(manager.url)
if err != nil {
return err
}
manager.logger.Info().
Str("url", manager.url).
Str("src", pipelineStr).
Msgf("starting pipeline")
manager.pipeline, err = gst.CreatePipeline(pipelineStr)
if err != nil {
return err
}
manager.pipeline.Play()
manager.pipelinesCounter.Inc()
manager.pipelinesActive.Set(1)
return nil
}
func (manager *BroacastManagerCtx) destroyPipeline() {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline == nil {
return
}
manager.pipeline.Destroy()
manager.logger.Info().Msgf("destroying pipeline")
manager.pipeline = nil
manager.pipelinesActive.Set(0)
}

269
internal/capture/manager.go Normal file
View file

@ -0,0 +1,269 @@
package capture
import (
"errors"
"fmt"
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/codec"
)
type CaptureManagerCtx struct {
logger zerolog.Logger
desktop types.DesktopManager
config *config.Capture
// sinks
broadcast *BroacastManagerCtx
screencast *ScreencastManagerCtx
audio *StreamSinkManagerCtx
video *StreamSelectorManagerCtx
// sources
webcam *StreamSrcManagerCtx
microphone *StreamSrcManagerCtx
}
func New(desktop types.DesktopManager, config *config.Capture) *CaptureManagerCtx {
logger := log.With().Str("module", "capture").Logger()
videos := map[string]types.StreamSinkManager{}
for video_id, cnf := range config.VideoPipelines {
pipelineConf := cnf
createPipeline := func() (string, error) {
if pipelineConf.GstPipeline != "" {
// replace {display} with valid display
return strings.Replace(pipelineConf.GstPipeline, "{display}", config.Display, 1), nil
}
screen := desktop.GetScreenSize()
pipeline, err := pipelineConf.GetPipeline(screen)
if err != nil {
return "", err
}
return fmt.Sprintf(
"ximagesrc display-name=%s show-pointer=false use-damage=false "+
"%s ! appsink name=appsink", config.Display, pipeline,
), nil
}
// trigger function to catch evaluation errors at startup
pipeline, err := createPipeline()
if err != nil {
logger.Panic().Err(err).
Str("video_id", video_id).
Msg("failed to create video pipeline")
}
logger.Info().
Str("video_id", video_id).
Str("pipeline", pipeline).
Msg("syntax check for video stream pipeline passed")
// append to videos
videos[video_id] = streamSinkNew(config.VideoCodec, createPipeline, video_id)
}
return &CaptureManagerCtx{
logger: logger,
desktop: desktop,
config: config,
// sinks
broadcast: broadcastNew(func(url string) (string, error) {
if config.BroadcastPipeline != "" {
var pipeline = config.BroadcastPipeline
// replace {display} with valid display
pipeline = strings.Replace(pipeline, "{display}", config.Display, 1)
// replace {device} with valid device
pipeline = strings.Replace(pipeline, "{device}", config.AudioDevice, 1)
// replace {url} with valid URL
return strings.Replace(pipeline, "{url}", url, 1), nil
}
return fmt.Sprintf(
"flvmux name=mux ! rtmpsink location='%s live=1' "+
"pulsesrc device=%s "+
"! audio/x-raw,channels=2 "+
"! audioconvert "+
"! queue "+
"! voaacenc bitrate=%d "+
"! mux. "+
"ximagesrc display-name=%s show-pointer=true use-damage=false "+
"! video/x-raw "+
"! videoconvert "+
"! queue "+
"! x264enc threads=4 bitrate=%d key-int-max=15 byte-stream=true tune=zerolatency speed-preset=%s "+
"! mux.", url, config.AudioDevice, config.BroadcastAudioBitrate*1000, config.Display, config.BroadcastVideoBitrate, config.BroadcastPreset,
), nil
}, config.BroadcastUrl),
screencast: screencastNew(config.ScreencastEnabled, func() string {
if config.ScreencastPipeline != "" {
// replace {display} with valid display
return strings.Replace(config.ScreencastPipeline, "{display}", config.Display, 1)
}
return fmt.Sprintf(
"ximagesrc display-name=%s show-pointer=true use-damage=false "+
"! video/x-raw,framerate=%s "+
"! videoconvert "+
"! queue "+
"! jpegenc quality=%s "+
"! appsink name=appsink", config.Display, config.ScreencastRate, config.ScreencastQuality,
)
}()),
audio: streamSinkNew(config.AudioCodec, func() (string, error) {
if config.AudioPipeline != "" {
// replace {device} with valid device
return strings.Replace(config.AudioPipeline, "{device}", config.AudioDevice, 1), nil
}
return fmt.Sprintf(
"pulsesrc device=%s "+
"! audio/x-raw,channels=2 "+
"! audioconvert "+
"! queue "+
"! %s "+
"! appsink name=appsink", config.AudioDevice, config.AudioCodec.Pipeline,
), nil
}, "audio"),
video: streamSelectorNew(config.VideoCodec, videos, config.VideoIDs),
// sources
webcam: streamSrcNew(config.WebcamEnabled, map[string]string{
codec.VP8().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
fmt.Sprintf("! application/x-rtp, payload=%d, encoding-name=VP8-DRAFT-IETF-01 ", codec.VP8().PayloadType) +
"! rtpvp8depay " +
"! decodebin " +
"! videoconvert " +
"! videorate " +
"! videoscale " +
fmt.Sprintf("! video/x-raw,width=%d,height=%d ", config.WebcamWidth, config.WebcamHeight) +
"! identity drop-allocation=true " +
fmt.Sprintf("! v4l2sink sync=false device=%s", config.WebcamDevice),
// TODO: Test this pipeline.
codec.VP9().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
"! application/x-rtp " +
"! rtpvp9depay " +
"! decodebin " +
"! videoconvert " +
"! videorate " +
"! videoscale " +
fmt.Sprintf("! video/x-raw,width=%d,height=%d ", config.WebcamWidth, config.WebcamHeight) +
"! identity drop-allocation=true " +
fmt.Sprintf("! v4l2sink sync=false device=%s", config.WebcamDevice),
// TODO: Test this pipeline.
codec.H264().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
"! application/x-rtp " +
"! rtph264depay " +
"! decodebin " +
"! videoconvert " +
"! videorate " +
"! videoscale " +
fmt.Sprintf("! video/x-raw,width=%d,height=%d ", config.WebcamWidth, config.WebcamHeight) +
"! identity drop-allocation=true " +
fmt.Sprintf("! v4l2sink sync=false device=%s", config.WebcamDevice),
}, "webcam"),
microphone: streamSrcNew(config.MicrophoneEnabled, map[string]string{
codec.Opus().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
fmt.Sprintf("! application/x-rtp, payload=%d, encoding-name=OPUS ", codec.Opus().PayloadType) +
"! rtpopusdepay " +
"! decodebin " +
fmt.Sprintf("! pulsesink device=%s", config.MicrophoneDevice),
// TODO: Test this pipeline.
codec.G722().Name: "appsrc format=time is-live=true do-timestamp=true name=appsrc " +
"! application/x-rtp clock-rate=8000 " +
"! rtpg722depay " +
"! decodebin " +
fmt.Sprintf("! pulsesink device=%s", config.MicrophoneDevice),
}, "microphone"),
}
}
func (manager *CaptureManagerCtx) Start() {
if manager.broadcast.Started() {
if err := manager.broadcast.createPipeline(); err != nil {
manager.logger.Panic().Err(err).Msg("unable to create broadcast pipeline")
}
}
manager.desktop.OnBeforeScreenSizeChange(func() {
manager.video.destroyPipelines()
if manager.broadcast.Started() {
manager.broadcast.destroyPipeline()
}
if manager.screencast.Started() {
manager.screencast.destroyPipeline()
}
})
manager.desktop.OnAfterScreenSizeChange(func() {
err := manager.video.recreatePipelines()
if err != nil {
manager.logger.Panic().Err(err).Msg("unable to recreate video pipelines")
}
if manager.broadcast.Started() {
err := manager.broadcast.createPipeline()
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
manager.logger.Panic().Err(err).Msg("unable to recreate broadcast pipeline")
}
}
if manager.screencast.Started() {
err := manager.screencast.createPipeline()
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
manager.logger.Panic().Err(err).Msg("unable to recreate screencast pipeline")
}
}
})
}
func (manager *CaptureManagerCtx) Shutdown() error {
manager.logger.Info().Msgf("shutdown")
manager.broadcast.shutdown()
manager.screencast.shutdown()
manager.audio.shutdown()
manager.video.shutdown()
manager.webcam.shutdown()
manager.microphone.shutdown()
return nil
}
func (manager *CaptureManagerCtx) Broadcast() types.BroadcastManager {
return manager.broadcast
}
func (manager *CaptureManagerCtx) Screencast() types.ScreencastManager {
return manager.screencast
}
func (manager *CaptureManagerCtx) Audio() types.StreamSinkManager {
return manager.audio
}
func (manager *CaptureManagerCtx) Video() types.StreamSelectorManager {
return manager.video
}
func (manager *CaptureManagerCtx) Webcam() types.StreamSrcManager {
return manager.webcam
}
func (manager *CaptureManagerCtx) Microphone() types.StreamSrcManager {
return manager.microphone
}

View file

@ -0,0 +1,257 @@
package capture
import (
"errors"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/pkg/gst"
"github.com/demodesk/neko/pkg/types"
)
// timeout between intervals, when screencast pipeline is checked
const screencastTimeout = 5 * time.Second
type ScreencastManagerCtx struct {
logger zerolog.Logger
mu sync.Mutex
wg sync.WaitGroup
pipeline gst.Pipeline
pipelineStr string
pipelineMu sync.Mutex
image types.Sample
imageMu sync.Mutex
tickerStop chan struct{}
enabled bool
started bool
expired int32
// metrics
imagesCounter prometheus.Counter
pipelinesCounter prometheus.Counter
pipelinesActive prometheus.Gauge
}
func screencastNew(enabled bool, pipelineStr string) *ScreencastManagerCtx {
logger := log.With().
Str("module", "capture").
Str("submodule", "screencast").
Logger()
manager := &ScreencastManagerCtx{
logger: logger,
pipelineStr: pipelineStr,
tickerStop: make(chan struct{}),
enabled: enabled,
started: false,
// metrics
imagesCounter: promauto.NewCounter(prometheus.CounterOpts{
Name: "screencast_images_total",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of created images.",
}),
pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{
Name: "pipelines_total",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of created pipelines.",
ConstLabels: map[string]string{
"submodule": "screencast",
"video_id": "main",
"codec_name": "-",
"codec_type": "-",
},
}),
pipelinesActive: promauto.NewGauge(prometheus.GaugeOpts{
Name: "pipelines_active",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of active pipelines.",
ConstLabels: map[string]string{
"submodule": "screencast",
"video_id": "main",
"codec_name": "-",
"codec_type": "-",
},
}),
}
manager.wg.Add(1)
go func() {
defer manager.wg.Done()
ticker := time.NewTicker(screencastTimeout)
defer ticker.Stop()
for {
select {
case <-manager.tickerStop:
return
case <-ticker.C:
if manager.Started() && !atomic.CompareAndSwapInt32(&manager.expired, 0, 1) {
manager.stop()
}
}
}
}()
return manager
}
func (manager *ScreencastManagerCtx) shutdown() {
manager.logger.Info().Msgf("shutdown")
manager.destroyPipeline()
close(manager.tickerStop)
manager.wg.Wait()
}
func (manager *ScreencastManagerCtx) Enabled() bool {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.enabled
}
func (manager *ScreencastManagerCtx) Started() bool {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.started
}
func (manager *ScreencastManagerCtx) Image() ([]byte, error) {
atomic.StoreInt32(&manager.expired, 0)
err := manager.start()
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
return nil, err
}
manager.imageMu.Lock()
defer manager.imageMu.Unlock()
if manager.image.Data == nil {
return nil, errors.New("image data not found")
}
return manager.image.Data, nil
}
func (manager *ScreencastManagerCtx) start() error {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.enabled {
return errors.New("screencast not enabled")
}
err := manager.createPipeline()
if err != nil {
return err
}
manager.started = true
return nil
}
func (manager *ScreencastManagerCtx) stop() {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.started = false
manager.destroyPipeline()
}
func (manager *ScreencastManagerCtx) createPipeline() error {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline != nil {
return types.ErrCapturePipelineAlreadyExists
}
var err error
manager.logger.Info().
Str("str", manager.pipelineStr).
Msgf("creating pipeline")
manager.pipeline, err = gst.CreatePipeline(manager.pipelineStr)
if err != nil {
return err
}
manager.pipeline.AttachAppsink("appsink")
manager.pipeline.Play()
manager.pipelinesCounter.Inc()
manager.pipelinesActive.Set(1)
// get first image
select {
case image, ok := <-manager.pipeline.Sample():
if !ok {
return errors.New("unable to get first image")
} else {
manager.setImage(image)
}
case <-time.After(1 * time.Second):
return errors.New("timeouted while waiting for first image")
}
manager.wg.Add(1)
pipeline := manager.pipeline
go func() {
manager.logger.Debug().Msg("started receiving images")
defer manager.wg.Done()
for {
image, ok := <-pipeline.Sample()
if !ok {
manager.logger.Debug().Msg("stopped receiving images")
return
}
manager.setImage(image)
}
}()
return nil
}
func (manager *ScreencastManagerCtx) setImage(image types.Sample) {
manager.imageMu.Lock()
manager.image = image
manager.imageMu.Unlock()
manager.imagesCounter.Inc()
}
func (manager *ScreencastManagerCtx) destroyPipeline() {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline == nil {
return
}
manager.pipeline.Destroy()
manager.logger.Info().Msgf("destroying pipeline")
manager.pipeline = nil
manager.pipelinesActive.Set(0)
}

View file

@ -0,0 +1,206 @@
package capture
import (
"errors"
"sort"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/codec"
)
type StreamSelectorManagerCtx struct {
logger zerolog.Logger
codec codec.RTPCodec
streams map[string]types.StreamSinkManager
streamIDs []string
}
func streamSelectorNew(codec codec.RTPCodec, streams map[string]types.StreamSinkManager, streamIDs []string) *StreamSelectorManagerCtx {
logger := log.With().
Str("module", "capture").
Str("submodule", "stream-selector").
Logger()
return &StreamSelectorManagerCtx{
logger: logger,
codec: codec,
streams: streams,
streamIDs: streamIDs,
}
}
func (manager *StreamSelectorManagerCtx) shutdown() {
manager.logger.Info().Msgf("shutdown")
manager.destroyPipelines()
}
func (manager *StreamSelectorManagerCtx) destroyPipelines() {
for _, stream := range manager.streams {
if stream.Started() {
stream.DestroyPipeline()
}
}
}
func (manager *StreamSelectorManagerCtx) recreatePipelines() error {
for _, stream := range manager.streams {
if stream.Started() {
err := stream.CreatePipeline()
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
return err
}
}
}
return nil
}
func (manager *StreamSelectorManagerCtx) IDs() []string {
return manager.streamIDs
}
func (manager *StreamSelectorManagerCtx) Codec() codec.RTPCodec {
return manager.codec
}
func (manager *StreamSelectorManagerCtx) GetStream(selector types.StreamSelector) (types.StreamSinkManager, bool) {
// select stream by ID
if selector.ID != "" {
// select lower stream
if selector.Type == types.StreamSelectorTypeLower {
var lastStream types.StreamSinkManager
for i := len(manager.streamIDs) - 1; i >= 0; i-- {
streamID := manager.streamIDs[i]
if streamID == selector.ID {
return lastStream, lastStream != nil
}
stream, ok := manager.streams[streamID]
if ok {
lastStream = stream
}
}
// we couldn't find a lower stream
return nil, false
}
// select higher stream
if selector.Type == types.StreamSelectorTypeHigher {
var lastStream types.StreamSinkManager
for _, streamID := range manager.streamIDs {
if streamID == selector.ID {
return lastStream, lastStream != nil
}
stream, ok := manager.streams[streamID]
if ok {
lastStream = stream
}
}
// we couldn't find a higher stream
return nil, false
}
// select exact stream
stream, ok := manager.streams[selector.ID]
return stream, ok
}
// select stream by bitrate
if selector.Bitrate != 0 {
// select stream by nearest bitrate
if selector.Type == types.StreamSelectorTypeNearest {
return manager.nearestBitrate(selector.Bitrate), true
}
// select lower stream
if selector.Type == types.StreamSelectorTypeLower {
// start from the highest stream, and go down, until we find a lower stream
for i := len(manager.streamIDs) - 1; i >= 0; i-- {
streamID := manager.streamIDs[i]
stream := manager.streams[streamID]
// if stream should be considered in calculation
considered := stream.Bitrate() != 0 && stream.Started()
if considered && stream.Bitrate() < selector.Bitrate {
return stream, true
}
}
// we couldn't find a lower stream
return nil, false
}
// select higher stream
if selector.Type == types.StreamSelectorTypeHigher {
// start from the lowest stream, and go up, until we find a higher stream
for _, streamID := range manager.streamIDs {
stream := manager.streams[streamID]
// if stream should be considered in calculation
considered := stream.Bitrate() != 0 && stream.Started()
if considered && stream.Bitrate() > selector.Bitrate {
return stream, true
}
}
// we couldn't find a higher stream
return nil, false
}
// select stream by exact bitrate
for _, stream := range manager.streams {
if stream.Bitrate() == selector.Bitrate {
return stream, true
}
}
}
// we couldn't find a stream
return nil, false
}
// TODO: This is a very naive implementation, we should use a binary search instead.
func (manager *StreamSelectorManagerCtx) nearestBitrate(bitrate uint64) types.StreamSinkManager {
type streamDiff struct {
id string
bitrateDiff int
}
sortDiff := func(a, b int) bool {
switch {
case a < 0 && b < 0:
return a > b
case a >= 0:
if b >= 0 {
return a <= b
}
return true
}
return false
}
var diffs []streamDiff
for _, stream := range manager.streams {
// if stream should be considered in calculation
considered := stream.Bitrate() != 0 && stream.Started()
if !considered {
continue
}
diffs = append(diffs, streamDiff{
id: stream.ID(),
bitrateDiff: int(bitrate) - int(stream.Bitrate()),
})
}
// no streams available
if len(diffs) == 0 {
// return first (lowest) stream
return manager.streams[manager.streamIDs[0]]
}
sort.Slice(diffs, func(i, j int) bool {
return sortDiff(diffs[i].bitrateDiff, diffs[j].bitrateDiff)
})
bestDiff := diffs[0]
return manager.streams[bestDiff.id]
}

View file

@ -0,0 +1,414 @@
package capture
import (
"errors"
"reflect"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/pkg/gst"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/codec"
)
var moveSinkListenerMu = sync.Mutex{}
type StreamSinkManagerCtx struct {
id string
// wait for a keyframe before sending samples
waitForKf bool
bitrate uint64 // atomic
brBuckets map[int]float64
logger zerolog.Logger
mu sync.Mutex
wg sync.WaitGroup
codec codec.RTPCodec
pipeline gst.Pipeline
pipelineMu sync.Mutex
pipelineFn func() (string, error)
listeners map[uintptr]types.SampleListener
listenersKf map[uintptr]types.SampleListener // keyframe lobby
listenersMu sync.Mutex
// metrics
currentListeners prometheus.Gauge
totalBytes prometheus.Counter
pipelinesCounter prometheus.Counter
pipelinesActive prometheus.Gauge
}
func streamSinkNew(codec codec.RTPCodec, pipelineFn func() (string, error), id string) *StreamSinkManagerCtx {
logger := log.With().
Str("module", "capture").
Str("submodule", "stream-sink").
Str("id", id).Logger()
manager := &StreamSinkManagerCtx{
id: id,
// only wait for keyframes if the codec is video
waitForKf: codec.IsVideo(),
bitrate: 0,
brBuckets: map[int]float64{},
logger: logger,
codec: codec,
pipelineFn: pipelineFn,
listeners: map[uintptr]types.SampleListener{},
listenersKf: map[uintptr]types.SampleListener{},
// metrics
currentListeners: promauto.NewGauge(prometheus.GaugeOpts{
Name: "streamsink_listeners",
Namespace: "neko",
Subsystem: "capture",
Help: "Current number of listeners for a pipeline.",
ConstLabels: map[string]string{
"video_id": id,
"codec_name": codec.Name,
"codec_type": codec.Type.String(),
},
}),
totalBytes: promauto.NewCounter(prometheus.CounterOpts{
Name: "streamsink_bytes",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of bytes created by the pipeline.",
ConstLabels: map[string]string{
"video_id": id,
"codec_name": codec.Name,
"codec_type": codec.Type.String(),
},
}),
pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{
Name: "pipelines_total",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of created pipelines.",
ConstLabels: map[string]string{
"submodule": "streamsink",
"video_id": id,
"codec_name": codec.Name,
"codec_type": codec.Type.String(),
},
}),
pipelinesActive: promauto.NewGauge(prometheus.GaugeOpts{
Name: "pipelines_active",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of active pipelines.",
ConstLabels: map[string]string{
"submodule": "streamsink",
"video_id": id,
"codec_name": codec.Name,
"codec_type": codec.Type.String(),
},
}),
}
return manager
}
func (manager *StreamSinkManagerCtx) shutdown() {
manager.logger.Info().Msgf("shutdown")
manager.listenersMu.Lock()
for key := range manager.listeners {
delete(manager.listeners, key)
}
for key := range manager.listenersKf {
delete(manager.listenersKf, key)
}
manager.listenersMu.Unlock()
manager.DestroyPipeline()
manager.wg.Wait()
}
func (manager *StreamSinkManagerCtx) ID() string {
return manager.id
}
func (manager *StreamSinkManagerCtx) Bitrate() uint64 {
return atomic.LoadUint64(&manager.bitrate)
}
func (manager *StreamSinkManagerCtx) Codec() codec.RTPCodec {
return manager.codec
}
func (manager *StreamSinkManagerCtx) start() error {
if len(manager.listeners)+len(manager.listenersKf) == 0 {
err := manager.CreatePipeline()
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
return err
}
manager.logger.Info().Msgf("first listener, starting")
}
return nil
}
func (manager *StreamSinkManagerCtx) stop() {
if len(manager.listeners)+len(manager.listenersKf) == 0 {
manager.DestroyPipeline()
manager.logger.Info().Msgf("last listener, stopping")
}
}
func (manager *StreamSinkManagerCtx) addListener(listener types.SampleListener) {
ptr := reflect.ValueOf(listener).Pointer()
emitKeyframe := false
manager.listenersMu.Lock()
if manager.waitForKf {
// if this is the first listener, we need to emit a keyframe
emitKeyframe = len(manager.listenersKf) == 0
// if we're waiting for a keyframe, add it to the keyframe lobby
manager.listenersKf[ptr] = listener
} else {
// otherwise, add it as a regular listener
manager.listeners[ptr] = listener
}
manager.listenersMu.Unlock()
manager.logger.Debug().Interface("ptr", ptr).Msgf("adding listener")
manager.currentListeners.Set(float64(manager.ListenersCount()))
// if we will be waiting for a keyframe, emit one now
if manager.pipeline != nil && emitKeyframe {
manager.pipeline.EmitVideoKeyframe()
}
}
func (manager *StreamSinkManagerCtx) removeListener(listener types.SampleListener) {
ptr := reflect.ValueOf(listener).Pointer()
manager.listenersMu.Lock()
delete(manager.listeners, ptr)
delete(manager.listenersKf, ptr) // if it's a keyframe listener, remove it too
manager.listenersMu.Unlock()
manager.logger.Debug().Interface("ptr", ptr).Msgf("removing listener")
manager.currentListeners.Set(float64(manager.ListenersCount()))
}
func (manager *StreamSinkManagerCtx) AddListener(listener types.SampleListener) error {
manager.mu.Lock()
defer manager.mu.Unlock()
if listener == nil {
return errors.New("listener cannot be nil")
}
// start if stopped
if err := manager.start(); err != nil {
return err
}
// add listener
manager.addListener(listener)
return nil
}
func (manager *StreamSinkManagerCtx) RemoveListener(listener types.SampleListener) error {
manager.mu.Lock()
defer manager.mu.Unlock()
if listener == nil {
return errors.New("listener cannot be nil")
}
// remove listener
manager.removeListener(listener)
// stop if started
manager.stop()
return nil
}
// moving listeners between streams ensures, that target pipeline is running
// before listener is added, and stops source pipeline if there are 0 listeners
func (manager *StreamSinkManagerCtx) MoveListenerTo(listener types.SampleListener, stream types.StreamSinkManager) error {
if listener == nil {
return errors.New("listener cannot be nil")
}
targetStream, ok := stream.(*StreamSinkManagerCtx)
if !ok {
return errors.New("target stream manager does not support moving listeners")
}
// we need to acquire both mutextes, from source stream and from target stream
// in order to do that safely (without possibility of deadlock) we need third
// global mutex, that ensures atomic locking
// lock global mutex
moveSinkListenerMu.Lock()
// lock source stream
manager.mu.Lock()
defer manager.mu.Unlock()
// lock target stream
targetStream.mu.Lock()
defer targetStream.mu.Unlock()
// unlock global mutex
moveSinkListenerMu.Unlock()
// start if stopped
if err := targetStream.start(); err != nil {
return err
}
// swap listeners
manager.removeListener(listener)
targetStream.addListener(listener)
// stop if started
manager.stop()
return nil
}
func (manager *StreamSinkManagerCtx) ListenersCount() int {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
return len(manager.listeners) + len(manager.listenersKf)
}
func (manager *StreamSinkManagerCtx) Started() bool {
return manager.ListenersCount() > 0
}
func (manager *StreamSinkManagerCtx) CreatePipeline() error {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline != nil {
return types.ErrCapturePipelineAlreadyExists
}
pipelineStr, err := manager.pipelineFn()
if err != nil {
return err
}
manager.logger.Info().
Str("codec", manager.codec.Name).
Str("src", pipelineStr).
Msgf("creating pipeline")
manager.pipeline, err = gst.CreatePipeline(pipelineStr)
if err != nil {
return err
}
manager.pipeline.AttachAppsink("appsink")
manager.pipeline.Play()
manager.wg.Add(1)
pipeline := manager.pipeline
go func() {
manager.logger.Debug().Msg("started emitting samples")
defer manager.wg.Done()
for {
sample, ok := <-pipeline.Sample()
if !ok {
manager.logger.Debug().Msg("stopped emitting samples")
return
}
manager.onSample(sample)
}
}()
manager.pipelinesCounter.Inc()
manager.pipelinesActive.Set(1)
return nil
}
func (manager *StreamSinkManagerCtx) saveSampleBitrate(timestamp time.Time, delta float64) {
// get unix timestamp in seconds
sec := timestamp.Unix()
// last bucket is timestamp rounded to 3 seconds - 1 second
last := int((sec - 1) % 3)
// current bucket is timestamp rounded to 3 seconds
curr := int(sec % 3)
// next bucket is timestamp rounded to 3 seconds + 1 second
next := int((sec + 1) % 3)
if manager.brBuckets[next] != 0 {
// atomic update bitrate
atomic.StoreUint64(&manager.bitrate, uint64(manager.brBuckets[last]))
// empty next bucket
manager.brBuckets[next] = 0
}
// add rate to current bucket
manager.brBuckets[curr] += delta
}
func (manager *StreamSinkManagerCtx) onSample(sample types.Sample) {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
// save to metrics
length := float64(sample.Length)
manager.totalBytes.Add(length)
manager.saveSampleBitrate(sample.Timestamp, length)
// if is not delta unit -> it can be decoded independently -> it is a keyframe
if manager.waitForKf && !sample.DeltaUnit && len(manager.listenersKf) > 0 {
// if current sample is a keyframe, move listeners from
// keyframe lobby to actual listeners map and clear lobby
for k, v := range manager.listenersKf {
manager.listeners[k] = v
}
manager.listenersKf = make(map[uintptr]types.SampleListener)
}
for _, l := range manager.listeners {
l.WriteSample(sample)
}
}
func (manager *StreamSinkManagerCtx) DestroyPipeline() {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline == nil {
return
}
manager.pipeline.Destroy()
manager.logger.Info().Msgf("destroying pipeline")
manager.pipeline = nil
manager.pipelinesActive.Set(0)
manager.brBuckets = make(map[int]float64)
atomic.StoreUint64(&manager.bitrate, 0)
}

View file

@ -0,0 +1,197 @@
package capture
import (
"errors"
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/pkg/gst"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/codec"
)
type StreamSrcManagerCtx struct {
logger zerolog.Logger
enabled bool
codecPipeline map[string]string // codec -> pipeline
codec codec.RTPCodec
pipeline gst.Pipeline
pipelineMu sync.Mutex
pipelineStr string
// metrics
pushedData map[string]prometheus.Summary
pipelinesCounter map[string]prometheus.Counter
pipelinesActive map[string]prometheus.Gauge
}
func streamSrcNew(enabled bool, codecPipeline map[string]string, video_id string) *StreamSrcManagerCtx {
logger := log.With().
Str("module", "capture").
Str("submodule", "stream-src").
Str("video_id", video_id).Logger()
pushedData := map[string]prometheus.Summary{}
pipelinesCounter := map[string]prometheus.Counter{}
pipelinesActive := map[string]prometheus.Gauge{}
for codecName, pipeline := range codecPipeline {
codec, ok := codec.ParseStr(codecName)
if !ok {
logger.Fatal().
Str("codec", codecName).
Str("pipeline", pipeline).
Msg("unknown codec name")
}
pushedData[codecName] = promauto.NewSummary(prometheus.SummaryOpts{
Name: "streamsrc_data_bytes",
Namespace: "neko",
Subsystem: "capture",
Help: "Data pushed to a pipeline (in bytes).",
ConstLabels: map[string]string{
"video_id": video_id,
"codec_name": codec.Name,
"codec_type": codec.Type.String(),
},
})
pipelinesCounter[codecName] = promauto.NewCounter(prometheus.CounterOpts{
Name: "pipelines_total",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of created pipelines.",
ConstLabels: map[string]string{
"submodule": "streamsrc",
"video_id": video_id,
"codec_name": codec.Name,
"codec_type": codec.Type.String(),
},
})
pipelinesActive[codecName] = promauto.NewGauge(prometheus.GaugeOpts{
Name: "pipelines_active",
Namespace: "neko",
Subsystem: "capture",
Help: "Total number of active pipelines.",
ConstLabels: map[string]string{
"submodule": "streamsrc",
"video_id": video_id,
"codec_name": codec.Name,
"codec_type": codec.Type.String(),
},
})
}
return &StreamSrcManagerCtx{
logger: logger,
enabled: enabled,
codecPipeline: codecPipeline,
// metrics
pushedData: pushedData,
pipelinesCounter: pipelinesCounter,
pipelinesActive: pipelinesActive,
}
}
func (manager *StreamSrcManagerCtx) shutdown() {
manager.logger.Info().Msgf("shutdown")
manager.Stop()
}
func (manager *StreamSrcManagerCtx) Codec() codec.RTPCodec {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
return manager.codec
}
func (manager *StreamSrcManagerCtx) Start(codec codec.RTPCodec) error {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline != nil {
return types.ErrCapturePipelineAlreadyExists
}
if !manager.enabled {
return errors.New("stream-src not enabled")
}
found := false
for codecName, pipeline := range manager.codecPipeline {
if codecName == codec.Name {
manager.pipelineStr = pipeline
manager.codec = codec
found = true
break
}
}
if !found {
return errors.New("no pipeline found for a codec")
}
var err error
manager.logger.Info().
Str("codec", manager.codec.Name).
Str("src", manager.pipelineStr).
Msgf("creating pipeline")
manager.pipeline, err = gst.CreatePipeline(manager.pipelineStr)
if err != nil {
return err
}
manager.pipeline.AttachAppsrc("appsrc")
manager.pipeline.Play()
manager.pipelinesCounter[manager.codec.Name].Inc()
manager.pipelinesActive[manager.codec.Name].Set(1)
return nil
}
func (manager *StreamSrcManagerCtx) Stop() {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline == nil {
return
}
manager.pipeline.Destroy()
manager.pipeline = nil
manager.logger.Info().
Str("codec", manager.codec.Name).
Str("src", manager.pipelineStr).
Msgf("destroying pipeline")
manager.pipelinesActive[manager.codec.Name].Set(0)
}
func (manager *StreamSrcManagerCtx) Push(bytes []byte) {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
if manager.pipeline == nil {
return
}
manager.pipeline.Push(bytes)
manager.pushedData[manager.codec.Name].Observe(float64(len(bytes)))
}
func (manager *StreamSrcManagerCtx) Started() bool {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
return manager.pipeline != nil
}

246
internal/config/capture.go Normal file
View file

@ -0,0 +1,246 @@
package config
import (
"os"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/codec"
"github.com/demodesk/neko/pkg/utils"
)
type Capture struct {
Display string
VideoCodec codec.RTPCodec
VideoIDs []string
VideoPipelines map[string]types.VideoConfig
AudioDevice string
AudioCodec codec.RTPCodec
AudioPipeline string
BroadcastAudioBitrate int
BroadcastVideoBitrate int
BroadcastPreset string
BroadcastPipeline string
BroadcastUrl string
ScreencastEnabled bool
ScreencastRate string
ScreencastQuality string
ScreencastPipeline string
WebcamEnabled bool
WebcamDevice string
WebcamWidth int
WebcamHeight int
MicrophoneEnabled bool
MicrophoneDevice string
}
func (Capture) Init(cmd *cobra.Command) error {
// audio
cmd.PersistentFlags().String("capture.audio.device", "audio_output.monitor", "pulseaudio device to capture")
if err := viper.BindPFlag("capture.audio.device", cmd.PersistentFlags().Lookup("capture.audio.device")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.audio.codec", "opus", "audio codec to be used")
if err := viper.BindPFlag("capture.audio.codec", cmd.PersistentFlags().Lookup("capture.audio.codec")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.audio.pipeline", "", "gstreamer pipeline used for audio streaming")
if err := viper.BindPFlag("capture.audio.pipeline", cmd.PersistentFlags().Lookup("capture.audio.pipeline")); err != nil {
return err
}
// videos
cmd.PersistentFlags().String("capture.video.codec", "vp8", "video codec to be used")
if err := viper.BindPFlag("capture.video.codec", cmd.PersistentFlags().Lookup("capture.video.codec")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("capture.video.ids", []string{}, "ordered list of video ids")
if err := viper.BindPFlag("capture.video.ids", cmd.PersistentFlags().Lookup("capture.video.ids")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.video.pipelines", "[]", "pipelines config in JSON used for video streaming")
if err := viper.BindPFlag("capture.video.pipelines", cmd.PersistentFlags().Lookup("capture.video.pipelines")); err != nil {
return err
}
// broadcast
cmd.PersistentFlags().Int("capture.broadcast.audio_bitrate", 128, "broadcast audio bitrate in KB/s")
if err := viper.BindPFlag("capture.broadcast.audio_bitrate", cmd.PersistentFlags().Lookup("capture.broadcast.audio_bitrate")); err != nil {
return err
}
cmd.PersistentFlags().Int("capture.broadcast.video_bitrate", 4096, "broadcast video bitrate in KB/s")
if err := viper.BindPFlag("capture.broadcast.video_bitrate", cmd.PersistentFlags().Lookup("capture.broadcast.video_bitrate")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.broadcast.preset", "veryfast", "broadcast speed preset for h264 encoding")
if err := viper.BindPFlag("capture.broadcast.preset", cmd.PersistentFlags().Lookup("capture.broadcast.preset")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.broadcast.pipeline", "", "gstreamer pipeline used for broadcasting")
if err := viper.BindPFlag("capture.broadcast.pipeline", cmd.PersistentFlags().Lookup("capture.broadcast.pipeline")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.broadcast.url", "", "initial URL for broadcasting, setting this value will automatically start broadcasting")
if err := viper.BindPFlag("capture.broadcast.url", cmd.PersistentFlags().Lookup("capture.broadcast.url")); err != nil {
return err
}
// screencast
cmd.PersistentFlags().Bool("capture.screencast.enabled", false, "enable screencast")
if err := viper.BindPFlag("capture.screencast.enabled", cmd.PersistentFlags().Lookup("capture.screencast.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.screencast.rate", "10/1", "screencast frame rate")
if err := viper.BindPFlag("capture.screencast.rate", cmd.PersistentFlags().Lookup("capture.screencast.rate")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.screencast.quality", "60", "screencast JPEG quality")
if err := viper.BindPFlag("capture.screencast.quality", cmd.PersistentFlags().Lookup("capture.screencast.quality")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.screencast.pipeline", "", "gstreamer pipeline used for screencasting")
if err := viper.BindPFlag("capture.screencast.pipeline", cmd.PersistentFlags().Lookup("capture.screencast.pipeline")); err != nil {
return err
}
// webcam
cmd.PersistentFlags().Bool("capture.webcam.enabled", false, "enable webcam stream")
if err := viper.BindPFlag("capture.webcam.enabled", cmd.PersistentFlags().Lookup("capture.webcam.enabled")); err != nil {
return err
}
// sudo apt install v4l2loopback-dkms v4l2loopback-utils
// sudo apt-get install linux-headers-`uname -r` linux-modules-extra-`uname -r`
// sudo modprobe v4l2loopback exclusive_caps=1
cmd.PersistentFlags().String("capture.webcam.device", "/dev/video0", "v4l2sink device used for webcam")
if err := viper.BindPFlag("capture.webcam.device", cmd.PersistentFlags().Lookup("capture.webcam.device")); err != nil {
return err
}
cmd.PersistentFlags().Int("capture.webcam.width", 1280, "webcam stream width")
if err := viper.BindPFlag("capture.webcam.width", cmd.PersistentFlags().Lookup("capture.webcam.width")); err != nil {
return err
}
cmd.PersistentFlags().Int("capture.webcam.height", 720, "webcam stream height")
if err := viper.BindPFlag("capture.webcam.height", cmd.PersistentFlags().Lookup("capture.webcam.height")); err != nil {
return err
}
// microphone
cmd.PersistentFlags().Bool("capture.microphone.enabled", true, "enable microphone stream")
if err := viper.BindPFlag("capture.microphone.enabled", cmd.PersistentFlags().Lookup("capture.microphone.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("capture.microphone.device", "audio_input", "pulseaudio device used for microphone")
if err := viper.BindPFlag("capture.microphone.device", cmd.PersistentFlags().Lookup("capture.microphone.device")); err != nil {
return err
}
return nil
}
func (s *Capture) Set() {
var ok bool
// Display is provided by env variable
s.Display = os.Getenv("DISPLAY")
// video
videoCodec := viper.GetString("capture.video.codec")
s.VideoCodec, ok = codec.ParseStr(videoCodec)
if !ok || !s.VideoCodec.IsVideo() {
log.Warn().Str("codec", videoCodec).Msgf("unknown video codec, using Vp8")
s.VideoCodec = codec.VP8()
}
s.VideoIDs = viper.GetStringSlice("capture.video.ids")
if err := viper.UnmarshalKey("capture.video.pipelines", &s.VideoPipelines, viper.DecodeHook(
utils.JsonStringAutoDecode(s.VideoPipelines),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse video pipelines")
}
// default video
if len(s.VideoPipelines) == 0 {
log.Warn().Msgf("no video pipelines specified, using defaults")
s.VideoCodec = codec.VP8()
s.VideoPipelines = map[string]types.VideoConfig{
"main": {
Fps: "25",
GstEncoder: "vp8enc",
GstParams: map[string]string{
"target-bitrate": "round(3072 * 650)",
"cpu-used": "4",
"end-usage": "cbr",
"threads": "4",
"deadline": "1",
"undershoot": "95",
"buffer-size": "(3072 * 4)",
"buffer-initial-size": "(3072 * 2)",
"buffer-optimal-size": "(3072 * 3)",
"keyframe-max-dist": "25",
"min-quantizer": "4",
"max-quantizer": "20",
},
},
}
s.VideoIDs = []string{"main"}
}
// audio
s.AudioDevice = viper.GetString("capture.audio.device")
s.AudioPipeline = viper.GetString("capture.audio.pipeline")
audioCodec := viper.GetString("capture.audio.codec")
s.AudioCodec, ok = codec.ParseStr(audioCodec)
if !ok || !s.AudioCodec.IsAudio() {
log.Warn().Str("codec", audioCodec).Msgf("unknown audio codec, using Opus")
s.AudioCodec = codec.Opus()
}
// broadcast
s.BroadcastAudioBitrate = viper.GetInt("capture.broadcast.audio_bitrate")
s.BroadcastVideoBitrate = viper.GetInt("capture.broadcast.video_bitrate")
s.BroadcastPreset = viper.GetString("capture.broadcast.preset")
s.BroadcastPipeline = viper.GetString("capture.broadcast.pipeline")
s.BroadcastUrl = viper.GetString("capture.broadcast.url")
// screencast
s.ScreencastEnabled = viper.GetBool("capture.screencast.enabled")
s.ScreencastRate = viper.GetString("capture.screencast.rate")
s.ScreencastQuality = viper.GetString("capture.screencast.quality")
s.ScreencastPipeline = viper.GetString("capture.screencast.pipeline")
// webcam
s.WebcamEnabled = viper.GetBool("capture.webcam.enabled")
s.WebcamDevice = viper.GetString("capture.webcam.device")
s.WebcamWidth = viper.GetInt("capture.webcam.width")
s.WebcamHeight = viper.GetInt("capture.webcam.height")
// microphone
s.MicrophoneEnabled = viper.GetBool("capture.microphone.enabled")
s.MicrophoneDevice = viper.GetString("capture.microphone.device")
}

View file

@ -0,0 +1,8 @@
package config
import "github.com/spf13/cobra"
type Config interface {
Init(cmd *cobra.Command) error
Set()
}

View file

@ -0,0 +1,91 @@
package config
import (
"os"
"regexp"
"strconv"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/demodesk/neko/pkg/types"
)
type Desktop struct {
Display string
ScreenSize types.ScreenSize
UseInputDriver bool
InputSocket string
Unminimize bool
UploadDrop bool
FileChooserDialog bool
}
func (Desktop) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().String("desktop.screen", "1280x720@30", "default screen size and framerate")
if err := viper.BindPFlag("desktop.screen", cmd.PersistentFlags().Lookup("desktop.screen")); err != nil {
return err
}
cmd.PersistentFlags().Bool("desktop.input.enabled", true, "whether custom xf86 input driver should be used to handle touchscreen")
if err := viper.BindPFlag("desktop.input.enabled", cmd.PersistentFlags().Lookup("desktop.input.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("desktop.input.socket", "/tmp/xf86-input-neko.sock", "socket path for custom xf86 input driver connection")
if err := viper.BindPFlag("desktop.input.socket", cmd.PersistentFlags().Lookup("desktop.input.socket")); err != nil {
return err
}
cmd.PersistentFlags().Bool("desktop.unminimize", true, "automatically unminimize window when it is minimized")
if err := viper.BindPFlag("desktop.unminimize", cmd.PersistentFlags().Lookup("desktop.unminimize")); err != nil {
return err
}
cmd.PersistentFlags().Bool("desktop.upload_drop", true, "whether drop upload is enabled")
if err := viper.BindPFlag("desktop.upload_drop", cmd.PersistentFlags().Lookup("desktop.upload_drop")); err != nil {
return err
}
cmd.PersistentFlags().Bool("desktop.file_chooser_dialog", false, "whether to handle file chooser dialog externally")
if err := viper.BindPFlag("desktop.file_chooser_dialog", cmd.PersistentFlags().Lookup("desktop.file_chooser_dialog")); err != nil {
return err
}
return nil
}
func (s *Desktop) Set() {
// Display is provided by env variable
s.Display = os.Getenv("DISPLAY")
s.ScreenSize = types.ScreenSize{
Width: 1280,
Height: 720,
Rate: 30,
}
r := regexp.MustCompile(`([0-9]{1,4})x([0-9]{1,4})@([0-9]{1,3})`)
res := r.FindStringSubmatch(viper.GetString("desktop.screen"))
if len(res) > 0 {
width, err1 := strconv.ParseInt(res[1], 10, 64)
height, err2 := strconv.ParseInt(res[2], 10, 64)
rate, err3 := strconv.ParseInt(res[3], 10, 64)
if err1 == nil && err2 == nil && err3 == nil {
s.ScreenSize.Width = int(width)
s.ScreenSize.Height = int(height)
s.ScreenSize.Rate = int16(rate)
}
}
s.UseInputDriver = viper.GetBool("desktop.input.enabled")
s.InputSocket = viper.GetString("desktop.input.socket")
s.Unminimize = viper.GetBool("desktop.unminimize")
s.UploadDrop = viper.GetBool("desktop.upload_drop")
s.FileChooserDialog = viper.GetBool("desktop.file_chooser_dialog")
}

128
internal/config/member.go Normal file
View file

@ -0,0 +1,128 @@
package config
import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/demodesk/neko/internal/member/file"
"github.com/demodesk/neko/internal/member/multiuser"
"github.com/demodesk/neko/internal/member/object"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type Member struct {
Provider string
// providers
File file.Config
Object object.Config
Multiuser multiuser.Config
}
func (Member) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().String("member.provider", "multiuser", "choose member provider")
if err := viper.BindPFlag("member.provider", cmd.PersistentFlags().Lookup("member.provider")); err != nil {
return err
}
// file provider
cmd.PersistentFlags().String("member.file.path", "", "member file provider: storage path")
if err := viper.BindPFlag("member.file.path", cmd.PersistentFlags().Lookup("member.file.path")); err != nil {
return err
}
cmd.PersistentFlags().Bool("member.file.hash", true, "member file provider: whether to hash passwords using sha256 (recommended)")
if err := viper.BindPFlag("member.file.hash", cmd.PersistentFlags().Lookup("member.file.hash")); err != nil {
return err
}
// object provider
cmd.PersistentFlags().String("member.object.users", "[]", "member object provider: users in JSON format")
if err := viper.BindPFlag("member.object.users", cmd.PersistentFlags().Lookup("member.object.users")); err != nil {
return err
}
// multiuser provider
cmd.PersistentFlags().String("member.multiuser.user_password", "neko", "member multiuser provider: user password")
if err := viper.BindPFlag("member.multiuser.user_password", cmd.PersistentFlags().Lookup("member.multiuser.user_password")); err != nil {
return err
}
cmd.PersistentFlags().String("member.multiuser.admin_password", "admin", "member multiuser provider: admin password")
if err := viper.BindPFlag("member.multiuser.admin_password", cmd.PersistentFlags().Lookup("member.multiuser.admin_password")); err != nil {
return err
}
cmd.PersistentFlags().String("member.multiuser.user_profile", "{}", "member multiuser provider: user profile in JSON format")
if err := viper.BindPFlag("member.multiuser.user_profile", cmd.PersistentFlags().Lookup("member.multiuser.user_profile")); err != nil {
return err
}
cmd.PersistentFlags().String("member.multiuser.admin_profile", "{}", "member multiuser provider: admin profile in JSON format")
if err := viper.BindPFlag("member.multiuser.admin_profile", cmd.PersistentFlags().Lookup("member.multiuser.admin_profile")); err != nil {
return err
}
return nil
}
func (s *Member) Set() {
s.Provider = viper.GetString("member.provider")
// file provider
s.File.Path = viper.GetString("member.file.path")
s.File.Hash = viper.GetBool("member.file.hash")
// object provider
if err := viper.UnmarshalKey("member.object.users", &s.Object.Users, viper.DecodeHook(
utils.JsonStringAutoDecode(s.Object.Users),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse member object users")
}
// multiuser provider
s.Multiuser.UserPassword = viper.GetString("member.multiuser.user_password")
s.Multiuser.AdminPassword = viper.GetString("member.multiuser.admin_password")
// default user profile
s.Multiuser.UserProfile = types.MemberProfile{
IsAdmin: false,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: false,
}
// override user profile
if err := viper.UnmarshalKey("member.multiuser.user_profile", &s.Multiuser.UserProfile, viper.DecodeHook(
utils.JsonStringAutoDecode(s.Multiuser.UserProfile),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse member multiuser user profile")
}
// default admin profile
s.Multiuser.AdminProfile = types.MemberProfile{
IsAdmin: true,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: true,
}
// override admin profile
if err := viper.UnmarshalKey("member.multiuser.admin_profile", &s.Multiuser.AdminProfile, viper.DecodeHook(
utils.JsonStringAutoDecode(s.Multiuser.AdminProfile),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile")
}
}

View file

@ -0,0 +1,37 @@
package config
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Plugins struct {
Enabled bool
Dir string
Required bool
}
func (Plugins) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().Bool("plugins.enabled", false, "load plugins in runtime")
if err := viper.BindPFlag("plugins.enabled", cmd.PersistentFlags().Lookup("plugins.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("plugins.dir", "./bin/plugins", "path to neko plugins to load")
if err := viper.BindPFlag("plugins.dir", cmd.PersistentFlags().Lookup("plugins.dir")); err != nil {
return err
}
cmd.PersistentFlags().Bool("plugins.required", false, "if true, neko will exit if there is an error when loading a plugin")
if err := viper.BindPFlag("plugins.required", cmd.PersistentFlags().Lookup("plugins.required")); err != nil {
return err
}
return nil
}
func (s *Plugins) Set() {
s.Enabled = viper.GetBool("plugins.enabled")
s.Dir = viper.GetString("plugins.dir")
s.Required = viper.GetBool("plugins.required")
}

97
internal/config/root.go Normal file
View file

@ -0,0 +1,97 @@
package config
import (
"os"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Root struct {
Config string
LogLevel zerolog.Level
LogTime string
LogJson bool
LogNocolor bool
LogDir string
}
func (Root) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().StringP("config", "c", "", "configuration file path")
if err := viper.BindPFlag("config", cmd.PersistentFlags().Lookup("config")); err != nil {
return err
}
// just a shortcut
cmd.PersistentFlags().BoolP("debug", "d", false, "enable debug mode")
if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); 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
}
cmd.PersistentFlags().String("log.time", "unix", "time format used in logs (unix, unixms, unixmicro)")
if err := viper.BindPFlag("log.time", cmd.PersistentFlags().Lookup("log.time")); err != nil {
return err
}
cmd.PersistentFlags().Bool("log.json", false, "logs in JSON format")
if err := viper.BindPFlag("log.json", cmd.PersistentFlags().Lookup("log.json")); err != nil {
return err
}
cmd.PersistentFlags().Bool("log.nocolor", false, "no ANSI colors in non-JSON output")
if err := viper.BindPFlag("log.nocolor", cmd.PersistentFlags().Lookup("log.nocolor")); err != nil {
return err
}
cmd.PersistentFlags().String("log.dir", "", "logging directory to store logs")
if err := viper.BindPFlag("log.dir", cmd.PersistentFlags().Lookup("log.dir")); err != nil {
return err
}
return nil
}
func (s *Root) Set() {
s.Config = viper.GetString("config")
logLevel := viper.GetString("log.level")
level, err := zerolog.ParseLevel(logLevel)
if err != nil {
log.Warn().Msgf("unknown log level %s", logLevel)
} else {
s.LogLevel = level
}
logTime := viper.GetString("log.time")
switch logTime {
case "unix":
s.LogTime = zerolog.TimeFormatUnix
case "unixms":
s.LogTime = zerolog.TimeFormatUnixMs
case "unixmicro":
s.LogTime = zerolog.TimeFormatUnixMicro
default:
log.Warn().Msgf("unknown log time %s", logTime)
}
s.LogJson = viper.GetBool("log.json")
s.LogNocolor = viper.GetBool("log.nocolor")
s.LogDir = viper.GetString("log.dir")
if viper.GetBool("debug") && s.LogLevel != zerolog.TraceLevel {
s.LogLevel = zerolog.DebugLevel
}
// support for NO_COLOR env variable: https://no-color.org/
if os.Getenv("NO_COLOR") != "" {
s.LogNocolor = true
}
}

103
internal/config/server.go Normal file
View file

@ -0,0 +1,103 @@
package config
import (
"path"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/demodesk/neko/pkg/utils"
)
type Server struct {
Cert string
Key string
Bind string
Proxy bool
Static string
PathPrefix string
PProf bool
Metrics bool
CORS []string
}
func (Server) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().String("server.bind", "127.0.0.1:8080", "address/port/socket to serve neko")
if err := viper.BindPFlag("server.bind", cmd.PersistentFlags().Lookup("server.bind")); err != nil {
return err
}
cmd.PersistentFlags().String("server.cert", "", "path to the SSL cert used to secure the neko server")
if err := viper.BindPFlag("server.cert", cmd.PersistentFlags().Lookup("server.cert")); err != nil {
return err
}
cmd.PersistentFlags().String("server.key", "", "path to the SSL key used to secure the neko server")
if err := viper.BindPFlag("server.key", cmd.PersistentFlags().Lookup("server.key")); err != nil {
return err
}
cmd.PersistentFlags().Bool("server.proxy", false, "trust reverse proxy headers")
if err := viper.BindPFlag("server.proxy", cmd.PersistentFlags().Lookup("server.proxy")); err != nil {
return err
}
cmd.PersistentFlags().String("server.static", "", "path to neko client files to serve")
if err := viper.BindPFlag("server.static", cmd.PersistentFlags().Lookup("server.static")); err != nil {
return err
}
cmd.PersistentFlags().String("server.path_prefix", "/", "path prefix for HTTP requests")
if err := viper.BindPFlag("server.path_prefix", cmd.PersistentFlags().Lookup("server.path_prefix")); err != nil {
return err
}
cmd.PersistentFlags().Bool("server.pprof", false, "enable pprof endpoint available at /debug/pprof")
if err := viper.BindPFlag("server.pprof", cmd.PersistentFlags().Lookup("server.pprof")); err != nil {
return err
}
cmd.PersistentFlags().Bool("server.metrics", true, "enable prometheus metrics available at /metrics")
if err := viper.BindPFlag("server.metrics", cmd.PersistentFlags().Lookup("server.metrics")); err != nil {
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")
if err := viper.BindPFlag("server.cors", cmd.PersistentFlags().Lookup("server.cors")); err != nil {
return err
}
return nil
}
func (s *Server) Set() {
s.Cert = viper.GetString("server.cert")
s.Key = viper.GetString("server.key")
s.Bind = viper.GetString("server.bind")
s.Proxy = viper.GetBool("server.proxy")
s.Static = viper.GetString("server.static")
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("server.path_prefix")))
s.PProf = viper.GetBool("server.pprof")
s.Metrics = viper.GetBool("server.metrics")
s.CORS = viper.GetStringSlice("server.cors")
in, _ := utils.ArrayIn("*", s.CORS)
if len(s.CORS) == 0 || in {
s.CORS = []string{"*"}
}
}
func (s *Server) HasCors() bool {
return len(s.CORS) > 0
}
func (s *Server) AllowOrigin(origin string) bool {
// if CORS is disabled, allow all origins
if len(s.CORS) == 0 {
return true
}
// if CORS is enabled, allow only origins in the list
in, _ := utils.ArrayIn(origin, s.CORS)
return in || s.CORS[0] == "*"
}

100
internal/config/session.go Normal file
View file

@ -0,0 +1,100 @@
package config
import (
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Session struct {
File string
PrivateMode bool
LockedControls bool
ImplicitHosting bool
InactiveCursors bool
MercifulReconnect bool
APIToken string
CookieEnabled bool
CookieName string
CookieExpiration time.Duration
CookieSecure bool
}
func (Session) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().String("session.file", "", "if sessions should be stored in a file, otherwise they will be stored only in memory")
if err := viper.BindPFlag("session.file", cmd.PersistentFlags().Lookup("session.file")); err != nil {
return err
}
cmd.PersistentFlags().Bool("session.private_mode", false, "whether private mode should be enabled initially")
if err := viper.BindPFlag("session.private_mode", cmd.PersistentFlags().Lookup("session.private_mode")); err != nil {
return err
}
cmd.PersistentFlags().Bool("session.locked_controls", false, "whether controls should be locked for users initially")
if err := viper.BindPFlag("session.locked_controls", cmd.PersistentFlags().Lookup("session.locked_controls")); err != nil {
return err
}
cmd.PersistentFlags().Bool("session.implicit_hosting", true, "allow implicit control switching")
if err := viper.BindPFlag("session.implicit_hosting", cmd.PersistentFlags().Lookup("session.implicit_hosting")); err != nil {
return err
}
cmd.PersistentFlags().Bool("session.inactive_cursors", false, "show inactive cursors on the screen")
if err := viper.BindPFlag("session.inactive_cursors", cmd.PersistentFlags().Lookup("session.inactive_cursors")); err != nil {
return err
}
cmd.PersistentFlags().Bool("session.merciful_reconnect", true, "allow reconnecting to websocket even if previous connection was not closed")
if err := viper.BindPFlag("session.merciful_reconnect", cmd.PersistentFlags().Lookup("session.merciful_reconnect")); err != nil {
return err
}
cmd.PersistentFlags().String("session.api_token", "", "API token for interacting with external services")
if err := viper.BindPFlag("session.api_token", cmd.PersistentFlags().Lookup("session.api_token")); err != nil {
return err
}
// cookie
cmd.PersistentFlags().Bool("session.cookie.enabled", true, "whether cookies authentication should be enabled")
if err := viper.BindPFlag("session.cookie.enabled", cmd.PersistentFlags().Lookup("session.cookie.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("session.cookie.name", "NEKO_SESSION", "name of the cookie that holds token")
if err := viper.BindPFlag("session.cookie.name", cmd.PersistentFlags().Lookup("session.cookie.name")); err != nil {
return err
}
cmd.PersistentFlags().Int("session.cookie.expiration", 365*24, "expiration of the cookie in hours")
if err := viper.BindPFlag("session.cookie.expiration", cmd.PersistentFlags().Lookup("session.cookie.expiration")); err != nil {
return err
}
cmd.PersistentFlags().Bool("session.cookie.secure", true, "use secure cookies")
if err := viper.BindPFlag("session.cookie.secure", cmd.PersistentFlags().Lookup("session.cookie.secure")); err != nil {
return err
}
return nil
}
func (s *Session) Set() {
s.File = viper.GetString("session.file")
s.PrivateMode = viper.GetBool("session.private_mode")
s.LockedControls = viper.GetBool("session.locked_controls")
s.ImplicitHosting = viper.GetBool("session.implicit_hosting")
s.InactiveCursors = viper.GetBool("session.inactive_cursors")
s.MercifulReconnect = viper.GetBool("session.merciful_reconnect")
s.APIToken = viper.GetString("session.api_token")
s.CookieEnabled = viper.GetBool("session.cookie.enabled")
s.CookieName = viper.GetString("session.cookie.name")
s.CookieExpiration = time.Duration(viper.GetInt("session.cookie.expiration")) * time.Hour
s.CookieSecure = viper.GetBool("session.cookie.secure")
}

273
internal/config/webrtc.go Normal file
View file

@ -0,0 +1,273 @@
package config
import (
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
// default stun server
const defStunSrv = "stun:stun.l.google.com:19302"
type WebRTCEstimator struct {
Enabled bool
Passive bool
Debug bool
InitialBitrate int
// how often to read and process bandwidth estimation reports
ReadInterval time.Duration
// how long to wait for stable connection (only neutral or upward trend) before upgrading
StableDuration time.Duration
// how long to wait for unstable connection (downward trend) before downgrading
UnstableDuration time.Duration
// how long to wait for stalled connection (neutral trend with low bandwidth) before downgrading
StalledDuration time.Duration
// how long to wait before downgrading again after previous downgrade
DowngradeBackoff time.Duration
// how long to wait before upgrading again after previous upgrade
UpgradeBackoff time.Duration
// how bigger the difference between estimated and stream bitrate must be to trigger upgrade/downgrade
DiffThreshold float64
}
type WebRTC struct {
ICELite bool
ICETrickle bool
ICEServersFrontend []types.ICEServer
ICEServersBackend []types.ICEServer
EphemeralMin uint16
EphemeralMax uint16
TCPMux int
UDPMux int
NAT1To1IPs []string
IpRetrievalUrl string
Estimator WebRTCEstimator
}
func (WebRTC) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().Bool("webrtc.icelite", false, "configures whether or not the ICE agent should be a lite agent")
if err := viper.BindPFlag("webrtc.icelite", cmd.PersistentFlags().Lookup("webrtc.icelite")); err != nil {
return err
}
cmd.PersistentFlags().Bool("webrtc.icetrickle", true, "configures whether cadidates should be sent asynchronously using Trickle ICE")
if err := viper.BindPFlag("webrtc.icetrickle", cmd.PersistentFlags().Lookup("webrtc.icetrickle")); err != nil {
return err
}
// Looks like this is conflicting with the frontend and backend ICE servers since latest versions
//cmd.PersistentFlags().String("webrtc.iceservers", "[]", "Global STUN and TURN servers in JSON format with `urls`, `username` and `credential` keys")
//if err := viper.BindPFlag("webrtc.iceservers", cmd.PersistentFlags().Lookup("webrtc.iceservers")); err != nil {
// return err
//}
cmd.PersistentFlags().String("webrtc.iceservers.frontend", "[]", "Frontend only STUN and TURN servers in JSON format with `urls`, `username` and `credential` keys")
if err := viper.BindPFlag("webrtc.iceservers.frontend", cmd.PersistentFlags().Lookup("webrtc.iceservers.frontend")); err != nil {
return err
}
cmd.PersistentFlags().String("webrtc.iceservers.backend", "[]", "Backend only STUN and TURN servers in JSON format with `urls`, `username` and `credential` keys")
if err := viper.BindPFlag("webrtc.iceservers.backend", cmd.PersistentFlags().Lookup("webrtc.iceservers.backend")); err != nil {
return err
}
cmd.PersistentFlags().String("webrtc.epr", "", "limits the pool of ephemeral ports that ICE UDP connections can allocate from")
if err := viper.BindPFlag("webrtc.epr", cmd.PersistentFlags().Lookup("webrtc.epr")); err != nil {
return err
}
cmd.PersistentFlags().Int("webrtc.tcpmux", 0, "single TCP mux port for all peers")
if err := viper.BindPFlag("webrtc.tcpmux", cmd.PersistentFlags().Lookup("webrtc.tcpmux")); err != nil {
return err
}
cmd.PersistentFlags().Int("webrtc.udpmux", 0, "single UDP mux port for all peers, replaces EPR")
if err := viper.BindPFlag("webrtc.udpmux", cmd.PersistentFlags().Lookup("webrtc.udpmux")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("webrtc.nat1to1", []string{}, "sets a list of external IP addresses of 1:1 (D)NAT and a candidate type for which the external IP address is used")
if err := viper.BindPFlag("webrtc.nat1to1", cmd.PersistentFlags().Lookup("webrtc.nat1to1")); err != nil {
return err
}
cmd.PersistentFlags().String("webrtc.ip_retrieval_url", "https://checkip.amazonaws.com", "URL address used for retrieval of the external IP address")
if err := viper.BindPFlag("webrtc.ip_retrieval_url", cmd.PersistentFlags().Lookup("webrtc.ip_retrieval_url")); err != nil {
return err
}
// bandwidth estimator
cmd.PersistentFlags().Bool("webrtc.estimator.enabled", false, "enables the bandwidth estimator")
if err := viper.BindPFlag("webrtc.estimator.enabled", cmd.PersistentFlags().Lookup("webrtc.estimator.enabled")); err != nil {
return err
}
cmd.PersistentFlags().Bool("webrtc.estimator.passive", false, "passive estimator mode, when it does not switch pipelines, only estimates")
if err := viper.BindPFlag("webrtc.estimator.passive", cmd.PersistentFlags().Lookup("webrtc.estimator.passive")); err != nil {
return err
}
cmd.PersistentFlags().Bool("webrtc.estimator.debug", false, "enables debug logging for the bandwidth estimator")
if err := viper.BindPFlag("webrtc.estimator.debug", cmd.PersistentFlags().Lookup("webrtc.estimator.debug")); err != nil {
return err
}
cmd.PersistentFlags().Int("webrtc.estimator.initial_bitrate", 1_000_000, "initial bitrate for the bandwidth estimator")
if err := viper.BindPFlag("webrtc.estimator.initial_bitrate", cmd.PersistentFlags().Lookup("webrtc.estimator.initial_bitrate")); err != nil {
return err
}
cmd.PersistentFlags().Duration("webrtc.estimator.read_interval", 2*time.Second, "how often to read and process bandwidth estimation reports")
if err := viper.BindPFlag("webrtc.estimator.read_interval", cmd.PersistentFlags().Lookup("webrtc.estimator.read_interval")); err != nil {
return err
}
cmd.PersistentFlags().Duration("webrtc.estimator.stable_duration", 12*time.Second, "how long to wait for stable connection (upward or neutral trend) before upgrading")
if err := viper.BindPFlag("webrtc.estimator.stable_duration", cmd.PersistentFlags().Lookup("webrtc.estimator.stable_duration")); err != nil {
return err
}
cmd.PersistentFlags().Duration("webrtc.estimator.unstable_duration", 6*time.Second, "how long to wait for stalled connection (neutral trend with low bandwidth) before downgrading")
if err := viper.BindPFlag("webrtc.estimator.unstable_duration", cmd.PersistentFlags().Lookup("webrtc.estimator.unstable_duration")); err != nil {
return err
}
cmd.PersistentFlags().Duration("webrtc.estimator.stalled_duration", 24*time.Second, "how long to wait for stalled bandwidth estimation before downgrading")
if err := viper.BindPFlag("webrtc.estimator.stalled_duration", cmd.PersistentFlags().Lookup("webrtc.estimator.stalled_duration")); err != nil {
return err
}
cmd.PersistentFlags().Duration("webrtc.estimator.downgrade_backoff", 10*time.Second, "how long to wait before downgrading again after previous downgrade")
if err := viper.BindPFlag("webrtc.estimator.downgrade_backoff", cmd.PersistentFlags().Lookup("webrtc.estimator.downgrade_backoff")); err != nil {
return err
}
cmd.PersistentFlags().Duration("webrtc.estimator.upgrade_backoff", 5*time.Second, "how long to wait before upgrading again after previous upgrade")
if err := viper.BindPFlag("webrtc.estimator.upgrade_backoff", cmd.PersistentFlags().Lookup("webrtc.estimator.upgrade_backoff")); err != nil {
return err
}
cmd.PersistentFlags().Float64("webrtc.estimator.diff_threshold", 0.15, "how bigger the difference between estimated and stream bitrate must be to trigger upgrade/downgrade")
if err := viper.BindPFlag("webrtc.estimator.diff_threshold", cmd.PersistentFlags().Lookup("webrtc.estimator.diff_threshold")); err != nil {
return err
}
return nil
}
func (s *WebRTC) Set() {
s.ICELite = viper.GetBool("webrtc.icelite")
s.ICETrickle = viper.GetBool("webrtc.icetrickle")
// parse frontend ice servers
if err := viper.UnmarshalKey("webrtc.iceservers.frontend", &s.ICEServersFrontend, viper.DecodeHook(
utils.JsonStringAutoDecode([]types.ICEServer{}),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse frontend ICE servers")
}
// parse backend ice servers
if err := viper.UnmarshalKey("webrtc.iceservers.backend", &s.ICEServersBackend, viper.DecodeHook(
utils.JsonStringAutoDecode([]types.ICEServer{}),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse backend ICE servers")
}
if s.ICELite && len(s.ICEServersBackend) > 0 {
log.Warn().Msgf("ICE Lite is enabled, but backend ICE servers are configured. Backend ICE servers will be ignored.")
}
// if no frontend or backend ice servers are configured
if len(s.ICEServersFrontend) == 0 && len(s.ICEServersBackend) == 0 {
// parse global ice servers
var iceServers []types.ICEServer
if err := viper.UnmarshalKey("webrtc.iceservers", &iceServers, viper.DecodeHook(
utils.JsonStringAutoDecode([]types.ICEServer{}),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse global ICE servers")
}
// add default stun server if none are configured
if len(iceServers) == 0 {
iceServers = append(iceServers, types.ICEServer{
URLs: []string{defStunSrv},
})
}
s.ICEServersFrontend = append(s.ICEServersFrontend, iceServers...)
s.ICEServersBackend = append(s.ICEServersBackend, iceServers...)
}
s.TCPMux = viper.GetInt("webrtc.tcpmux")
s.UDPMux = viper.GetInt("webrtc.udpmux")
epr := viper.GetString("webrtc.epr")
if epr != "" {
ports := strings.SplitN(epr, "-", -1)
if len(ports) > 1 {
min, err := strconv.ParseUint(ports[0], 10, 16)
if err != nil {
log.Panic().Err(err).Msgf("unable to parse ephemeral min port")
}
max, err := strconv.ParseUint(ports[1], 10, 16)
if err != nil {
log.Panic().Err(err).Msgf("unable to parse ephemeral max port")
}
s.EphemeralMin = uint16(min)
s.EphemeralMax = uint16(max)
}
if s.EphemeralMin > s.EphemeralMax {
log.Panic().Msgf("ephemeral min port cannot be bigger than max")
}
}
if epr == "" && s.TCPMux == 0 && s.UDPMux == 0 {
// using default epr range
s.EphemeralMin = 59000
s.EphemeralMax = 59100
log.Warn().
Uint16("min", s.EphemeralMin).
Uint16("max", s.EphemeralMax).
Msgf("no TCP, UDP mux or epr specified, using default epr range")
}
s.NAT1To1IPs = viper.GetStringSlice("webrtc.nat1to1")
s.IpRetrievalUrl = viper.GetString("webrtc.ip_retrieval_url")
if s.IpRetrievalUrl != "" && len(s.NAT1To1IPs) == 0 {
ip, err := utils.HttpRequestGET(s.IpRetrievalUrl)
if err == nil {
s.NAT1To1IPs = append(s.NAT1To1IPs, ip)
} else {
log.Warn().Err(err).Msgf("IP retrieval failed")
}
}
// bandwidth estimator
s.Estimator.Enabled = viper.GetBool("webrtc.estimator.enabled")
s.Estimator.Passive = viper.GetBool("webrtc.estimator.passive")
s.Estimator.Debug = viper.GetBool("webrtc.estimator.debug")
s.Estimator.InitialBitrate = viper.GetInt("webrtc.estimator.initial_bitrate")
s.Estimator.ReadInterval = viper.GetDuration("webrtc.estimator.read_interval")
s.Estimator.StableDuration = viper.GetDuration("webrtc.estimator.stable_duration")
s.Estimator.UnstableDuration = viper.GetDuration("webrtc.estimator.unstable_duration")
s.Estimator.StalledDuration = viper.GetDuration("webrtc.estimator.stalled_duration")
s.Estimator.DowngradeBackoff = viper.GetDuration("webrtc.estimator.downgrade_backoff")
s.Estimator.UpgradeBackoff = viper.GetDuration("webrtc.estimator.upgrade_backoff")
s.Estimator.DiffThreshold = viper.GetFloat64("webrtc.estimator.diff_threshold")
}

View file

@ -0,0 +1,122 @@
package desktop
import (
"bytes"
"fmt"
"os/exec"
"strings"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/xevent"
)
func (manager *DesktopManagerCtx) ClipboardGetText() (*types.ClipboardText, error) {
text, err := manager.ClipboardGetBinary("STRING")
if err != nil {
return nil, err
}
// Rich text must not always be available, can fail silently.
html, _ := manager.ClipboardGetBinary("text/html")
return &types.ClipboardText{
Text: string(text),
HTML: string(html),
}, nil
}
func (manager *DesktopManagerCtx) ClipboardSetText(data types.ClipboardText) error {
// TODO: Refactor.
// Current implementation is unable to set multiple targets. HTML
// is set, if available. Otherwise plain text.
if data.HTML != "" {
return manager.ClipboardSetBinary("text/html", []byte(data.HTML))
}
return manager.ClipboardSetBinary("STRING", []byte(data.Text))
}
func (manager *DesktopManagerCtx) ClipboardGetBinary(mime string) ([]byte, error) {
cmd := exec.Command("xclip", "-selection", "clipboard", "-out", "-target", mime)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
msg := strings.TrimSpace(stderr.String())
return nil, fmt.Errorf("%s", msg)
}
return stdout.Bytes(), nil
}
func (manager *DesktopManagerCtx) ClipboardSetBinary(mime string, data []byte) error {
cmd := exec.Command("xclip", "-selection", "clipboard", "-in", "-target", mime)
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
// TODO: Refactor.
// We need to wait until the data came to the clipboard.
wait := make(chan struct{})
xevent.Emmiter.Once("clipboard-updated", func(payload ...any) {
wait <- struct{}{}
})
err = cmd.Start()
if err != nil {
msg := strings.TrimSpace(stderr.String())
return fmt.Errorf("%s", msg)
}
_, err = stdin.Write(data)
if err != nil {
return err
}
stdin.Close()
// TODO: Refactor.
// cmd.Wait()
<-wait
return nil
}
func (manager *DesktopManagerCtx) ClipboardGetTargets() ([]string, error) {
cmd := exec.Command("xclip", "-selection", "clipboard", "-out", "-target", "TARGETS")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
msg := strings.TrimSpace(stderr.String())
return nil, fmt.Errorf("%s", msg)
}
var response []string
targets := strings.Split(stdout.String(), "\n")
for _, target := range targets {
if target == "" {
continue
}
if !strings.Contains(target, "/") {
continue
}
response = append(response, target)
}
return response, nil
}

68
internal/desktop/drop.go Normal file
View file

@ -0,0 +1,68 @@
package desktop
import (
"time"
"github.com/demodesk/neko/pkg/drop"
)
// repeat move event multiple times
const dropMoveRepeat = 4
// wait after each repeated move event
const dropMoveDelay = 100 * time.Millisecond
func (manager *DesktopManagerCtx) DropFiles(x int, y int, files []string) bool {
mu.Lock()
defer mu.Unlock()
drop.Emmiter.Clear()
drop.Emmiter.Once("create", func(payload ...any) {
manager.Move(0, 0)
})
drop.Emmiter.Once("cursor-enter", func(payload ...any) {
//nolint
manager.ButtonDown(1)
})
drop.Emmiter.Once("button-press", func(payload ...any) {
manager.Move(x, y)
})
drop.Emmiter.Once("begin", func(payload ...any) {
for i := 0; i < dropMoveRepeat; i++ {
manager.Move(x, y)
time.Sleep(dropMoveDelay)
}
//nolint
manager.ButtonUp(1)
})
finished := make(chan bool)
drop.Emmiter.Once("finish", func(payload ...any) {
b, ok := payload[0].(bool)
// workaround until https://github.com/kataras/go-events/pull/8 is merged
if !ok {
b = (payload[0].([]any))[0].(bool)
}
finished <- b
})
manager.ResetKeys()
go drop.OpenWindow(files)
select {
case succeeded := <-finished:
return succeeded
case <-time.After(1 * time.Second):
drop.CloseWindow()
return false
}
}
func (manager *DesktopManagerCtx) IsUploadDropEnabled() bool {
return manager.config.UploadDrop
}

View file

@ -0,0 +1,102 @@
package desktop
import (
"errors"
"os/exec"
"github.com/demodesk/neko/pkg/xorg"
)
// name of the window that is being controlled
const fileChooserDialogName = "Open File"
// short sleep value between fake user interactions
const fileChooserDialogShortSleep = "0.2"
// long sleep value between fake user interactions
const fileChooserDialogLongSleep = "0.4"
func (manager *DesktopManagerCtx) HandleFileChooserDialog(uri string) error {
mu.Lock()
defer mu.Unlock()
// TODO: Use native API.
err1 := exec.Command(
"xdotool",
"search", "--name", fileChooserDialogName, "windowfocus",
"sleep", fileChooserDialogShortSleep,
"key", "--clearmodifiers", "ctrl+l",
"type", "--args", "1", uri+"//",
"sleep", fileChooserDialogShortSleep,
"key", "Delete", // remove autocomplete results
"sleep", fileChooserDialogShortSleep,
"key", "Return",
"sleep", fileChooserDialogLongSleep,
"key", "Down",
"key", "--clearmodifiers", "ctrl+a",
"key", "Return",
"sleep", fileChooserDialogLongSleep,
).Run()
if err1 != nil {
return err1
}
// TODO: Use native API.
err2 := exec.Command(
"xdotool",
"search", "--name", fileChooserDialogName,
).Run()
// if last command didn't return error, consider dialog as still open
if err2 == nil {
return errors.New("unable to select files in dialog")
}
return nil
}
func (manager *DesktopManagerCtx) CloseFileChooserDialog() {
for i := 0; i < 5; i++ {
mu.Lock()
manager.logger.Debug().Msg("attempting to close file chooser dialog")
// TODO: Use native API.
err := exec.Command(
"xdotool",
"search", "--name", fileChooserDialogName, "windowfocus",
).Run()
if err != nil {
mu.Unlock()
manager.logger.Info().Msg("file chooser dialog is closed")
return
}
// custom press Alt + F4
// because xdotool is failing to send proper Alt+F4
//nolint
manager.KeyPress(xorg.XK_Alt_L, xorg.XK_F4)
mu.Unlock()
}
}
func (manager *DesktopManagerCtx) IsFileChooserDialogEnabled() bool {
return manager.config.FileChooserDialog
}
func (manager *DesktopManagerCtx) IsFileChooserDialogOpened() bool {
mu.Lock()
defer mu.Unlock()
// TODO: Use native API.
err := exec.Command(
"xdotool",
"search", "--name", fileChooserDialogName,
).Run()
return err == nil
}

138
internal/desktop/manager.go Normal file
View file

@ -0,0 +1,138 @@
package desktop
import (
"sync"
"time"
"github.com/kataras/go-events"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/xevent"
"github.com/demodesk/neko/pkg/xinput"
"github.com/demodesk/neko/pkg/xorg"
)
var mu = sync.Mutex{}
type DesktopManagerCtx struct {
logger zerolog.Logger
wg sync.WaitGroup
shutdown chan struct{}
emmiter events.EventEmmiter
config *config.Desktop
screenSize types.ScreenSize // cached screen size
input xinput.Driver
}
func New(config *config.Desktop) *DesktopManagerCtx {
var input xinput.Driver
if config.UseInputDriver {
input = xinput.NewDriver(config.InputSocket)
} else {
input = xinput.NewDummy()
}
return &DesktopManagerCtx{
logger: log.With().Str("module", "desktop").Logger(),
shutdown: make(chan struct{}),
emmiter: events.New(),
config: config,
screenSize: config.ScreenSize,
input: input,
}
}
func (manager *DesktopManagerCtx) Start() {
if xorg.DisplayOpen(manager.config.Display) {
manager.logger.Panic().Str("display", manager.config.Display).Msg("unable to open display")
}
// X11 can throw errors below, and the default error handler exits
xevent.SetupErrorHandler()
xorg.GetScreenConfigurations()
screenSize, err := xorg.ChangeScreenSize(manager.config.ScreenSize)
if err != nil {
manager.logger.Err(err).
Str("screen_size", screenSize.String()).
Msgf("unable to set initial screen size")
} else {
// cache screen size
manager.screenSize = screenSize
manager.logger.Info().
Str("screen_size", screenSize.String()).
Msgf("setting initial screen size")
}
err = manager.input.Connect()
if err != nil {
// TODO: fail silently to dummy driver?
manager.logger.Panic().Err(err).Msg("unable to connect to input driver")
}
// set up event listeners
xevent.Unminimize = manager.config.Unminimize
xevent.FileChooserDialog = manager.config.FileChooserDialog
go xevent.EventLoop(manager.config.Display)
// in case it was opened
if manager.config.FileChooserDialog {
go manager.CloseFileChooserDialog()
}
manager.OnEventError(func(error_code uint8, message string, request_code uint8, minor_code uint8) {
manager.logger.Warn().
Uint8("error_code", error_code).
Str("message", message).
Uint8("request_code", request_code).
Uint8("minor_code", minor_code).
Msg("X event error occured")
})
manager.wg.Add(1)
go func() {
defer manager.wg.Done()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
const debounceDuration = 10 * time.Second
for {
select {
case <-manager.shutdown:
return
case <-ticker.C:
xorg.CheckKeys(debounceDuration)
manager.input.Debounce(debounceDuration)
}
}
}()
}
func (manager *DesktopManagerCtx) OnBeforeScreenSizeChange(listener func()) {
manager.emmiter.On("before_screen_size_change", func(payload ...any) {
listener()
})
}
func (manager *DesktopManagerCtx) OnAfterScreenSizeChange(listener func()) {
manager.emmiter.On("after_screen_size_change", func(payload ...any) {
listener()
})
}
func (manager *DesktopManagerCtx) Shutdown() error {
manager.logger.Info().Msgf("shutdown")
close(manager.shutdown)
manager.wg.Wait()
xorg.DisplayClose()
return nil
}

View file

@ -0,0 +1,35 @@
package desktop
import (
"github.com/demodesk/neko/pkg/xevent"
)
func (manager *DesktopManagerCtx) OnCursorChanged(listener func(serial uint64)) {
xevent.Emmiter.On("cursor-changed", func(payload ...any) {
listener(payload[0].(uint64))
})
}
func (manager *DesktopManagerCtx) OnClipboardUpdated(listener func()) {
xevent.Emmiter.On("clipboard-updated", func(payload ...any) {
listener()
})
}
func (manager *DesktopManagerCtx) OnFileChooserDialogOpened(listener func()) {
xevent.Emmiter.On("file-chooser-dialog-opened", func(payload ...any) {
listener()
})
}
func (manager *DesktopManagerCtx) OnFileChooserDialogClosed(listener func()) {
xevent.Emmiter.On("file-chooser-dialog-closed", func(payload ...any) {
listener()
})
}
func (manager *DesktopManagerCtx) OnEventError(listener func(error_code uint8, message string, request_code uint8, minor_code uint8)) {
xevent.Emmiter.On("event-error", func(payload ...any) {
listener(payload[0].(uint8), payload[1].(string), payload[2].(uint8), payload[3].(uint8))
})
}

View file

@ -0,0 +1,36 @@
package desktop
import "github.com/demodesk/neko/pkg/xinput"
func (manager *DesktopManagerCtx) inputRelToAbs(x, y int) (int, int) {
return (x * xinput.AbsX) / manager.screenSize.Width, (y * xinput.AbsY) / manager.screenSize.Height
}
func (manager *DesktopManagerCtx) HasTouchSupport() bool {
// we assume now, that if the input driver is enabled, we have touch support
return manager.config.UseInputDriver
}
func (manager *DesktopManagerCtx) TouchBegin(touchId uint32, x, y int, pressure uint8) error {
mu.Lock()
defer mu.Unlock()
x, y = manager.inputRelToAbs(x, y)
return manager.input.TouchBegin(touchId, x, y, pressure)
}
func (manager *DesktopManagerCtx) TouchUpdate(touchId uint32, x, y int, pressure uint8) error {
mu.Lock()
defer mu.Unlock()
x, y = manager.inputRelToAbs(x, y)
return manager.input.TouchUpdate(touchId, x, y, pressure)
}
func (manager *DesktopManagerCtx) TouchEnd(touchId uint32, x, y int, pressure uint8) error {
mu.Lock()
defer mu.Unlock()
x, y = manager.inputRelToAbs(x, y)
return manager.input.TouchEnd(touchId, x, y, pressure)
}

202
internal/desktop/xorg.go Normal file
View file

@ -0,0 +1,202 @@
package desktop
import (
"image"
"os/exec"
"regexp"
"time"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/xorg"
)
func (manager *DesktopManagerCtx) Move(x, y int) {
xorg.Move(x, y)
}
func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) {
return xorg.GetCursorPosition()
}
func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) {
xorg.Scroll(deltaX, deltaY, controlKey)
}
func (manager *DesktopManagerCtx) ButtonDown(code uint32) error {
return xorg.ButtonDown(code)
}
func (manager *DesktopManagerCtx) KeyDown(code uint32) error {
return xorg.KeyDown(code)
}
func (manager *DesktopManagerCtx) ButtonUp(code uint32) error {
return xorg.ButtonUp(code)
}
func (manager *DesktopManagerCtx) KeyUp(code uint32) error {
return xorg.KeyUp(code)
}
func (manager *DesktopManagerCtx) ButtonPress(code uint32) error {
xorg.ResetKeys()
defer xorg.ResetKeys()
return xorg.ButtonDown(code)
}
func (manager *DesktopManagerCtx) KeyPress(codes ...uint32) error {
xorg.ResetKeys()
defer xorg.ResetKeys()
for _, code := range codes {
if err := xorg.KeyDown(code); err != nil {
return err
}
}
if len(codes) > 1 {
time.Sleep(10 * time.Millisecond)
}
return nil
}
func (manager *DesktopManagerCtx) ResetKeys() {
xorg.ResetKeys()
}
func (manager *DesktopManagerCtx) ScreenConfigurations() []types.ScreenSize {
var configs []types.ScreenSize
for _, size := range xorg.ScreenConfigurations {
for _, fps := range size.Rates {
// filter out all irrelevant rates
if fps > 60 || (fps > 30 && fps%10 != 0) {
continue
}
configs = append(configs, types.ScreenSize{
Width: size.Width,
Height: size.Height,
Rate: fps,
})
}
}
return configs
}
func (manager *DesktopManagerCtx) SetScreenSize(screenSize types.ScreenSize) (types.ScreenSize, error) {
mu.Lock()
manager.emmiter.Emit("before_screen_size_change")
defer func() {
manager.emmiter.Emit("after_screen_size_change")
mu.Unlock()
}()
screenSize, err := xorg.ChangeScreenSize(screenSize)
if err == nil {
// cache the new screen size
manager.screenSize = screenSize
}
return screenSize, err
}
func (manager *DesktopManagerCtx) GetScreenSize() types.ScreenSize {
return xorg.GetScreenSize()
}
func (manager *DesktopManagerCtx) SetKeyboardMap(kbd types.KeyboardMap) error {
// TOOD: Use native API.
cmd := exec.Command("setxkbmap", "-layout", kbd.Layout, "-variant", kbd.Variant)
_, err := cmd.Output()
return err
}
func (manager *DesktopManagerCtx) GetKeyboardMap() (*types.KeyboardMap, error) {
// TOOD: Use native API.
cmd := exec.Command("setxkbmap", "-query")
res, err := cmd.Output()
if err != nil {
return nil, err
}
kbd := types.KeyboardMap{}
re := regexp.MustCompile(`layout:\s+(.*)\n`)
arr := re.FindStringSubmatch(string(res))
if len(arr) > 1 {
kbd.Layout = arr[1]
}
re = regexp.MustCompile(`variant:\s+(.*)\n`)
arr = re.FindStringSubmatch(string(res))
if len(arr) > 1 {
kbd.Variant = arr[1]
}
return &kbd, nil
}
func (manager *DesktopManagerCtx) SetKeyboardModifiers(mod types.KeyboardModifiers) {
if mod.Shift != nil {
xorg.SetKeyboardModifier(xorg.KbdModShift, *mod.Shift)
}
if mod.CapsLock != nil {
xorg.SetKeyboardModifier(xorg.KbdModCapsLock, *mod.CapsLock)
}
if mod.Control != nil {
xorg.SetKeyboardModifier(xorg.KbdModControl, *mod.Control)
}
if mod.Alt != nil {
xorg.SetKeyboardModifier(xorg.KbdModAlt, *mod.Alt)
}
if mod.NumLock != nil {
xorg.SetKeyboardModifier(xorg.KbdModNumLock, *mod.NumLock)
}
if mod.Meta != nil {
xorg.SetKeyboardModifier(xorg.KbdModMeta, *mod.Meta)
}
if mod.Super != nil {
xorg.SetKeyboardModifier(xorg.KbdModSuper, *mod.Super)
}
if mod.AltGr != nil {
xorg.SetKeyboardModifier(xorg.KbdModAltGr, *mod.AltGr)
}
}
func (manager *DesktopManagerCtx) GetKeyboardModifiers() types.KeyboardModifiers {
modifiers := xorg.GetKeyboardModifiers()
isset := func(mod xorg.KbdMod) *bool {
x := modifiers&mod != 0
return &x
}
return types.KeyboardModifiers{
Shift: isset(xorg.KbdModShift),
CapsLock: isset(xorg.KbdModCapsLock),
Control: isset(xorg.KbdModControl),
Alt: isset(xorg.KbdModAlt),
NumLock: isset(xorg.KbdModNumLock),
Meta: isset(xorg.KbdModMeta),
Super: isset(xorg.KbdModSuper),
AltGr: isset(xorg.KbdModAltGr),
}
}
func (manager *DesktopManagerCtx) GetCursorImage() *types.CursorImage {
return xorg.GetCursorImage()
}
func (manager *DesktopManagerCtx) GetScreenshotImage() *image.RGBA {
return xorg.GetScreenshotImage()
}

123
internal/http/batch.go Normal file
View file

@ -0,0 +1,123 @@
package http
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type BatchRequest struct {
Path string `json:"path"`
Method string `json:"method"`
Body json.RawMessage `json:"body,omitempty"`
}
type BatchResponse struct {
Path string `json:"path"`
Method string `json:"method"`
Body json.RawMessage `json:"body,omitempty"`
Status int `json:"status"`
}
func (b *BatchResponse) Error(httpErr *utils.HTTPError) (err error) {
b.Body, err = json.Marshal(httpErr)
b.Status = httpErr.Code
return
}
type batchHandler struct {
Router types.Router
PathPrefix string
Excluded []string
}
func (b *batchHandler) Handle(w http.ResponseWriter, r *http.Request) error {
var requests []BatchRequest
if err := json.NewDecoder(r.Body).Decode(&requests); err != nil {
return err
}
responses := make([]BatchResponse, len(requests))
for i, request := range requests {
res := BatchResponse{
Path: request.Path,
Method: request.Method,
}
if !strings.HasPrefix(request.Path, b.PathPrefix) {
res.Error(utils.HttpBadRequest("this path is not allowed in batch requests"))
responses[i] = res
continue
}
if exists, _ := utils.ArrayIn(request.Path, b.Excluded); exists {
res.Error(utils.HttpBadRequest("this path is excluded from batch requests"))
responses[i] = res
continue
}
// prepare request
req, err := http.NewRequest(request.Method, request.Path, bytes.NewBuffer(request.Body))
if err != nil {
return err
}
// copy headers
for k, vv := range r.Header {
for _, v := range vv {
req.Header.Add(k, v)
}
}
// execute request
rr := newResponseRecorder()
b.Router.ServeHTTP(rr, req)
// read response
body, err := io.ReadAll(rr.Body)
if err != nil {
return err
}
// write response
responses[i] = BatchResponse{
Path: request.Path,
Method: request.Method,
Body: body,
Status: rr.Code,
}
}
return utils.HttpSuccess(w, responses)
}
type responseRecorder struct {
Code int
HeaderMap http.Header
Body *bytes.Buffer
}
func newResponseRecorder() *responseRecorder {
return &responseRecorder{
Code: http.StatusOK,
HeaderMap: make(http.Header),
Body: new(bytes.Buffer),
}
}
func (w *responseRecorder) Header() http.Header {
return w.HeaderMap
}
func (w *responseRecorder) Write(b []byte) (int, error) {
return w.Body.Write(b)
}
func (w *responseRecorder) WriteHeader(code int) {
w.Code = code
}

36
internal/http/debug.go Normal file
View file

@ -0,0 +1,36 @@
package http
import (
"net/http"
"net/http/pprof"
"github.com/go-chi/chi"
"github.com/demodesk/neko/pkg/types"
)
func pprofHandler(r types.Router) {
r.Get("/debug/pprof/", func(w http.ResponseWriter, r *http.Request) error {
pprof.Index(w, r)
return nil
})
r.Get("/debug/pprof/{action}", func(w http.ResponseWriter, r *http.Request) error {
action := chi.URLParam(r, "action")
switch action {
case "cmdline":
pprof.Cmdline(w, r)
case "profile":
pprof.Profile(w, r)
case "symbol":
pprof.Symbol(w, r)
case "trace":
pprof.Trace(w, r)
default:
pprof.Handler(action).ServeHTTP(w, r)
}
return nil
})
}

135
internal/http/logger.go Normal file
View file

@ -0,0 +1,135 @@
package http
import (
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"github.com/rs/zerolog"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type logFormatter struct {
logger zerolog.Logger
}
func (l *logFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
// exclude health & metrics from logs
if r.RequestURI == "/health" || r.RequestURI == "/metrics" {
return &nulllog{}
}
req := map[string]any{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
req["id"] = reqID
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
req["scheme"] = scheme
req["proto"] = r.Proto
req["method"] = r.Method
req["remote"] = r.RemoteAddr
req["agent"] = r.UserAgent()
req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
return &logEntry{
logger: l.logger.With().Interface("req", req).Logger(),
}
}
type logEntry struct {
logger zerolog.Logger
err error
panic *logPanic
session types.Session
}
type logPanic struct {
message string
stack string
}
func (e *logEntry) Panic(v any, stack []byte) {
e.panic = &logPanic{
message: fmt.Sprintf("%+v", v),
stack: string(stack),
}
}
func (e *logEntry) Error(err error) {
e.err = err
}
func (e *logEntry) SetSession(session types.Session) {
e.session = session
}
func (e *logEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
res := map[string]any{}
res["time"] = time.Now().UTC().Format(time.RFC1123)
res["status"] = status
res["bytes"] = bytes
res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0
logger := e.logger.With().Interface("res", res).Logger()
// add session ID to logs (if exists)
if e.session != nil {
logger = logger.With().Str("session_id", e.session.ID()).Logger()
}
// handle panic error message
if e.panic != nil {
logger.WithLevel(zerolog.PanicLevel).
Err(e.err).
Str("stack", e.panic.stack).
Msgf("request failed (%d): %s", status, e.panic.message)
return
}
// handle panic error message
if e.err != nil {
httpErr, ok := e.err.(*utils.HTTPError)
if !ok {
logger.Err(e.err).Msgf("request failed (%d)", status)
return
}
if httpErr.Message == "" {
httpErr.Message = http.StatusText(httpErr.Code)
}
var logLevel zerolog.Level
if httpErr.Code < 500 {
logLevel = zerolog.WarnLevel
} else {
logLevel = zerolog.ErrorLevel
}
message := httpErr.Message
if httpErr.InternalMsg != "" {
message = httpErr.InternalMsg
}
logger.WithLevel(logLevel).Err(httpErr.InternalErr).Msgf("request failed (%d): %s", status, message)
return
}
logger.Debug().Msgf("request complete (%d)", status)
}
type nulllog struct{}
func (e *nulllog) Panic(v any, stack []byte) {}
func (e *nulllog) Error(err error) {}
func (e *nulllog) SetSession(session types.Session) {}
func (e *nulllog) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
}

132
internal/http/manager.go Normal file
View file

@ -0,0 +1,132 @@
package http
import (
"context"
"net/http"
"os"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/pkg/types"
)
type HttpManagerCtx struct {
logger zerolog.Logger
config *config.Server
router types.Router
http *http.Server
}
func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server) *HttpManagerCtx {
logger := log.With().Str("module", "http").Logger()
opts := []RouterOption{
WithRequestID(), // create a request id for each request
}
// use real ip if behind proxy
// before logger so it can log the real ip
if config.Proxy {
opts = append(opts, WithRealIP())
}
opts = append(opts,
WithLogger(logger),
WithRecoverer(), // recover from panics without crashing server
)
if config.HasCors() {
opts = append(opts, WithCORS(config.AllowOrigin))
}
if config.PathPrefix != "/" {
opts = append(opts, WithPathPrefix(config.PathPrefix))
}
router := newRouter(opts...)
router.Route("/api", ApiManager.Route)
router.Get("/api/ws", WebSocketManager.Upgrade(func(r *http.Request) bool {
return config.AllowOrigin(r.Header.Get("Origin"))
}))
batch := batchHandler{
Router: router,
PathPrefix: "/api",
Excluded: []string{
"/api/batch", // do not allow batchception
"/api/ws",
},
}
router.Post("/api/batch", batch.Handle)
router.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
_, err := w.Write([]byte("true"))
return err
})
if config.Metrics {
router.Get("/metrics", func(w http.ResponseWriter, r *http.Request) error {
promhttp.Handler().ServeHTTP(w, r)
return nil
})
}
if config.Static != "" {
fs := http.FileServer(http.Dir(config.Static))
router.Get("/*", func(w http.ResponseWriter, r *http.Request) error {
_, err := os.Stat(config.Static + r.URL.Path)
if err == nil {
fs.ServeHTTP(w, r)
return nil
}
if os.IsNotExist(err) {
http.NotFound(w, r)
return nil
}
return err
})
}
if config.PProf {
pprofHandler(router)
}
return &HttpManagerCtx{
logger: logger,
config: config,
router: router,
http: &http.Server{
Addr: config.Bind,
Handler: router,
},
}
}
func (manager *HttpManagerCtx) Start() {
if manager.config.Cert != "" && manager.config.Key != "" {
go func() {
if err := manager.http.ListenAndServeTLS(manager.config.Cert, manager.config.Key); err != http.ErrServerClosed {
manager.logger.Panic().Err(err).Msg("unable to start https server")
}
}()
manager.logger.Info().Msgf("https listening on %s", manager.http.Addr)
} else {
go func() {
if err := manager.http.ListenAndServe(); err != http.ErrServerClosed {
manager.logger.Panic().Err(err).Msg("unable to start http server")
}
}()
manager.logger.Info().Msgf("http listening on %s", manager.http.Addr)
}
}
func (manager *HttpManagerCtx) Shutdown() error {
manager.logger.Info().Msg("shutdown")
return manager.http.Shutdown(context.Background())
}

172
internal/http/router.go Normal file
View file

@ -0,0 +1,172 @@
package http
import (
"net/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type RouterOption func(*router)
func WithRequestID() RouterOption {
return func(r *router) {
r.chi.Use(middleware.RequestID)
}
}
func WithLogger(logger zerolog.Logger) RouterOption {
return func(r *router) {
r.chi.Use(middleware.RequestLogger(&logFormatter{logger}))
}
}
func WithRecoverer() RouterOption {
return func(r *router) {
r.chi.Use(middleware.Recoverer)
}
}
func WithCORS(allowOrigin func(origin string) bool) RouterOption {
return func(r *router) {
r.chi.Use(cors.Handler(cors.Options{
AllowOriginFunc: func(r *http.Request, origin string) bool {
return allowOrigin(origin)
},
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
}
}
func WithPathPrefix(prefix string) RouterOption {
return func(r *router) {
r.chi.Use(func(h http.Handler) http.Handler {
return http.StripPrefix(prefix, h)
})
}
}
func WithRealIP() RouterOption {
return func(r *router) {
r.chi.Use(middleware.RealIP)
}
}
type router struct {
chi chi.Router
}
func newRouter(opts ...RouterOption) types.Router {
r := &router{chi.NewRouter()}
for _, opt := range opts {
opt(r)
}
return r
}
func (r *router) Group(fn func(types.Router)) {
r.chi.Group(func(c chi.Router) {
fn(&router{c})
})
}
func (r *router) Route(pattern string, fn func(types.Router)) {
r.chi.Route(pattern, func(c chi.Router) {
fn(&router{c})
})
}
func (r *router) Get(pattern string, fn types.RouterHandler) {
r.chi.Get(pattern, routeHandler(fn))
}
func (r *router) Post(pattern string, fn types.RouterHandler) {
r.chi.Post(pattern, routeHandler(fn))
}
func (r *router) Put(pattern string, fn types.RouterHandler) {
r.chi.Put(pattern, routeHandler(fn))
}
func (r *router) Patch(pattern string, fn types.RouterHandler) {
r.chi.Patch(pattern, routeHandler(fn))
}
func (r *router) Delete(pattern string, fn types.RouterHandler) {
r.chi.Delete(pattern, routeHandler(fn))
}
func (r *router) With(fn types.MiddlewareHandler) types.Router {
c := r.chi.With(middlewareHandler(fn))
return &router{c}
}
func (r *router) Use(fn types.MiddlewareHandler) {
r.chi.Use(middlewareHandler(fn))
}
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.chi.ServeHTTP(w, req)
}
func errorHandler(err error, w http.ResponseWriter, r *http.Request) {
httpErr, ok := err.(*utils.HTTPError)
if !ok {
httpErr = utils.HttpInternalServerError().WithInternalErr(err)
}
utils.HttpJsonResponse(w, httpErr.Code, httpErr)
}
func routeHandler(fn types.RouterHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// get custom log entry pointer from context
logEntry, _ := r.Context().Value(middleware.LogEntryCtxKey).(*logEntry)
if err := fn(w, r); err != nil {
logEntry.Error(err)
errorHandler(err, w, r)
}
// set session if exits
if session, ok := auth.GetSession(r); ok {
logEntry.SetSession(session)
}
}
}
func middlewareHandler(fn types.MiddlewareHandler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// get custom log entry pointer from context
logEntry, _ := r.Context().Value(middleware.LogEntryCtxKey).(*logEntry)
ctx, err := fn(w, r)
if err != nil {
logEntry.Error(err)
errorHandler(err, w, r)
// set session if exits
if session, ok := auth.GetSession(r); ok {
logEntry.SetSession(session)
}
return
}
if ctx != nil {
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,204 @@
package file
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"os"
"github.com/demodesk/neko/pkg/types"
)
func New(config Config) types.MemberProvider {
return &MemberProviderCtx{
config: config,
}
}
type MemberProviderCtx struct {
config Config
}
func (provider *MemberProviderCtx) hash(password string) string {
// if hash is disabled, return password as plain text
if !provider.config.Hash {
return password
}
sha256 := sha256.New()
sha256.Write([]byte(password))
hashedPassword := sha256.Sum(nil)
return base64.StdEncoding.EncodeToString(hashedPassword)
}
func (provider *MemberProviderCtx) Connect() error {
return nil
}
func (provider *MemberProviderCtx) Disconnect() error {
return nil
}
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
// id will be also username
id := username
entry, err := provider.getEntry(id)
if err != nil {
return "", types.MemberProfile{}, err
}
if entry.Password != provider.hash(password) {
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
}
return id, entry.Profile, nil
}
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
// id will be also username
id := username
entries, err := provider.deserialize()
if err != nil {
return "", err
}
_, ok := entries[id]
if ok {
return "", types.ErrMemberAlreadyExists
}
entries[id] = MemberEntry{
Password: provider.hash(password),
Profile: profile,
}
return id, provider.serialize(entries)
}
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
entries, err := provider.deserialize()
if err != nil {
return err
}
entry, ok := entries[id]
if !ok {
return types.ErrMemberDoesNotExist
}
entry.Profile = profile
entries[id] = entry
return provider.serialize(entries)
}
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
entries, err := provider.deserialize()
if err != nil {
return err
}
entry, ok := entries[id]
if !ok {
return types.ErrMemberDoesNotExist
}
entry.Password = provider.hash(password)
entries[id] = entry
return provider.serialize(entries)
}
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
entry, err := provider.getEntry(id)
if err != nil {
return types.MemberProfile{}, err
}
return entry.Profile, nil
}
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
profiles := map[string]types.MemberProfile{}
entries, err := provider.deserialize()
if err != nil {
return profiles, err
}
i := 0
for id, entry := range entries {
if i >= offset && (limit == 0 || i < offset+limit) {
profiles[id] = entry.Profile
}
i = i + 1
}
return profiles, nil
}
func (provider *MemberProviderCtx) Delete(id string) error {
entries, err := provider.deserialize()
if err != nil {
return err
}
_, ok := entries[id]
if !ok {
return types.ErrMemberDoesNotExist
}
delete(entries, id)
return provider.serialize(entries)
}
func (provider *MemberProviderCtx) deserialize() (map[string]MemberEntry, error) {
file, err := os.OpenFile(provider.config.Path, os.O_RDONLY|os.O_CREATE, os.ModePerm)
if err != nil {
return nil, err
}
raw, err := io.ReadAll(file)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return map[string]MemberEntry{}, nil
}
var entries map[string]MemberEntry
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
return nil, err
}
return entries, nil
}
func (provider *MemberProviderCtx) getEntry(id string) (MemberEntry, error) {
entries, err := provider.deserialize()
if err != nil {
return MemberEntry{}, err
}
entry, ok := entries[id]
if !ok {
return MemberEntry{}, types.ErrMemberDoesNotExist
}
return entry, nil
}
func (provider *MemberProviderCtx) serialize(data map[string]MemberEntry) error {
raw, err := json.Marshal(data)
if err != nil {
return err
}
return os.WriteFile(provider.config.Path, raw, os.ModePerm)
}

View file

@ -0,0 +1,48 @@
package file
import (
"encoding/json"
"testing"
"github.com/demodesk/neko/pkg/utils"
)
// Ensure that hashes are the same after encoding and decoding using json
func TestMemberProviderCtx_hash(t *testing.T) {
provider := &MemberProviderCtx{
config: Config{
Hash: true,
},
}
// generate random strings
passwords := []string{}
for i := 0; i < 10; i++ {
password, err := utils.NewUID(32)
if err != nil {
t.Errorf("utils.NewUID() returned error: %s", err)
}
passwords = append(passwords, password)
}
for _, password := range passwords {
hashedPassword := provider.hash(password)
// json encode password hash
hashedPasswordJSON, err := json.Marshal(hashedPassword)
if err != nil {
t.Errorf("json.Marshal() returned error: %s", err)
}
// json decode password hash json
var hashedPasswordStr string
err = json.Unmarshal(hashedPasswordJSON, &hashedPasswordStr)
if err != nil {
t.Errorf("json.Unmarshal() returned error: %s", err)
}
if hashedPasswordStr != hashedPassword {
t.Errorf("hashedPasswordStr: %s != hashedPassword: %s", hashedPasswordStr, hashedPassword)
}
}
}

View file

@ -0,0 +1,15 @@
package file
import (
"github.com/demodesk/neko/pkg/types"
)
type MemberEntry struct {
Password string `json:"password"`
Profile types.MemberProfile `json:"profile"`
}
type Config struct {
Path string
Hash bool
}

164
internal/member/manager.go Normal file
View file

@ -0,0 +1,164 @@
package member
import (
"errors"
"sync"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/internal/member/file"
"github.com/demodesk/neko/internal/member/multiuser"
"github.com/demodesk/neko/internal/member/noauth"
"github.com/demodesk/neko/internal/member/object"
"github.com/demodesk/neko/pkg/types"
)
func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx {
manager := &MemberManagerCtx{
logger: log.With().Str("module", "member").Logger(),
sessions: sessions,
config: config,
}
switch config.Provider {
case "file":
manager.provider = file.New(config.File)
case "object":
manager.provider = object.New(config.Object)
case "multiuser":
manager.provider = multiuser.New(config.Multiuser)
case "noauth":
fallthrough
default:
manager.provider = noauth.New()
}
return manager
}
type MemberManagerCtx struct {
logger zerolog.Logger
sessions types.SessionManager
config *config.Member
providerMu sync.Mutex
provider types.MemberProvider
loginMu sync.Mutex
}
func (manager *MemberManagerCtx) Connect() error {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
return manager.provider.Connect()
}
func (manager *MemberManagerCtx) Disconnect() error {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
return manager.provider.Disconnect()
}
func (manager *MemberManagerCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
return manager.provider.Authenticate(username, password)
}
func (manager *MemberManagerCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
return manager.provider.Insert(username, password, profile)
}
func (manager *MemberManagerCtx) Select(id string) (types.MemberProfile, error) {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
// get primarily from corresponding session, if exists
session, ok := manager.sessions.Get(id)
if ok {
return session.Profile(), nil
}
return manager.provider.Select(id)
}
func (manager *MemberManagerCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
return manager.provider.SelectAll(limit, offset)
}
func (manager *MemberManagerCtx) UpdateProfile(id string, profile types.MemberProfile) error {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
// update corresponding session, if exists
err := manager.sessions.Update(id, profile)
if err != nil && !errors.Is(err, types.ErrSessionNotFound) {
manager.logger.Err(err).Msg("error while updating session")
}
return manager.provider.UpdateProfile(id, profile)
}
func (manager *MemberManagerCtx) UpdatePassword(id string, password string) error {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
return manager.provider.UpdatePassword(id, password)
}
func (manager *MemberManagerCtx) Delete(id string) error {
manager.providerMu.Lock()
defer manager.providerMu.Unlock()
// destroy corresponding session, if exists
err := manager.sessions.Delete(id)
if err != nil && !errors.Is(err, types.ErrSessionNotFound) {
manager.logger.Err(err).Msg("error while deleting session")
}
return manager.provider.Delete(id)
}
//
// member -> session
//
func (manager *MemberManagerCtx) Login(username string, password string) (types.Session, string, error) {
manager.loginMu.Lock()
defer manager.loginMu.Unlock()
id, profile, err := manager.provider.Authenticate(username, password)
if err != nil {
return nil, "", err
}
session, ok := manager.sessions.Get(id)
if ok {
if session.State().IsConnected {
return nil, "", types.ErrSessionAlreadyConnected
}
// TODO: Replace session.
if err := manager.sessions.Delete(id); err != nil {
return nil, "", err
}
}
return manager.sessions.Create(id, profile)
}
func (manager *MemberManagerCtx) Logout(id string) error {
manager.loginMu.Lock()
defer manager.loginMu.Unlock()
return manager.sessions.Delete(id)
}

View file

@ -0,0 +1,82 @@
package multiuser
import (
"errors"
"fmt"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
func New(config Config) types.MemberProvider {
return &MemberProviderCtx{
config: config,
}
}
type MemberProviderCtx struct {
config Config
}
func (provider *MemberProviderCtx) Connect() error {
return nil
}
func (provider *MemberProviderCtx) Disconnect() error {
return nil
}
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
// generate random token
token, err := utils.NewUID(5)
if err != nil {
return "", types.MemberProfile{}, err
}
// id is username with token
id := fmt.Sprintf("%s-%s", username, token)
// if logged in as administrator
if provider.config.AdminPassword == password {
profile := provider.config.AdminProfile
if profile.Name == "" {
profile.Name = username
}
return id, profile, nil
}
// if logged in as user
if provider.config.UserPassword == password {
profile := provider.config.UserProfile
if profile.Name == "" {
profile.Name = username
}
return id, profile, nil
}
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
}
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
return "", errors.New("new user is created on first login in multiuser mode")
}
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
return nil
}
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
return errors.New("password can only be modified in config while in multiuser mode")
}
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
return types.MemberProfile{}, errors.New("cannot select user in multiuser mode")
}
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
return map[string]types.MemberProfile{}, nil
}
func (provider *MemberProviderCtx) Delete(id string) error {
return errors.New("cannot delete user in multiuser mode")
}

View file

@ -0,0 +1,10 @@
package multiuser
import "github.com/demodesk/neko/pkg/types"
type Config struct {
AdminPassword string
UserPassword string
AdminProfile types.MemberProfile
UserProfile types.MemberProfile
}

View file

@ -0,0 +1,75 @@
package noauth
import (
"errors"
"fmt"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
func New() types.MemberProvider {
return &MemberProviderCtx{
profile: types.MemberProfile{
IsAdmin: true,
CanLogin: true,
CanConnect: true,
CanWatch: true,
CanHost: true,
CanShareMedia: true,
CanAccessClipboard: true,
SendsInactiveCursor: true,
CanSeeInactiveCursors: true,
},
}
}
type MemberProviderCtx struct {
profile types.MemberProfile
}
func (provider *MemberProviderCtx) Connect() error {
return nil
}
func (provider *MemberProviderCtx) Disconnect() error {
return nil
}
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
// generate random token
token, err := utils.NewUID(5)
if err != nil {
return "", types.MemberProfile{}, err
}
// id is username with token
id := fmt.Sprintf("%s-%s", username, token)
provider.profile.Name = username
return id, provider.profile, nil
}
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
return "", errors.New("new user is created on first login in noauth mode")
}
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
return nil
}
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
return errors.New("noauth mode does not have password")
}
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
return types.MemberProfile{}, errors.New("cannot select user in noauth mode")
}
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
return map[string]types.MemberProfile{}, nil
}
func (provider *MemberProviderCtx) Delete(id string) error {
return errors.New("cannot delete user in noauth mode")
}

View file

@ -0,0 +1,124 @@
package object
import (
"github.com/demodesk/neko/pkg/types"
)
func New(config Config) types.MemberProvider {
return &MemberProviderCtx{
config: config,
entries: make(map[string]*memberEntry),
}
}
type MemberProviderCtx struct {
config Config
entries map[string]*memberEntry
}
func (provider *MemberProviderCtx) Connect() error {
var err error
for _, entry := range provider.config.Users {
_, err = provider.Insert(entry.Username, entry.Password, entry.Profile)
}
return err
}
func (provider *MemberProviderCtx) Disconnect() error {
return nil
}
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
// id will be also username
id := username
entry, ok := provider.entries[id]
if !ok {
return "", types.MemberProfile{}, types.ErrMemberDoesNotExist
}
// TODO: Use hash function.
if !entry.CheckPassword(password) {
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
}
return id, entry.profile, nil
}
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
// id will be also username
id := username
_, ok := provider.entries[id]
if ok {
return "", types.ErrMemberAlreadyExists
}
provider.entries[id] = &memberEntry{
// TODO: Use hash function.
password: password,
profile: profile,
}
return id, nil
}
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
entry, ok := provider.entries[id]
if !ok {
return types.ErrMemberDoesNotExist
}
entry.profile = profile
return nil
}
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
entry, ok := provider.entries[id]
if !ok {
return types.ErrMemberDoesNotExist
}
// TODO: Use hash function.
entry.password = password
return nil
}
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
entry, ok := provider.entries[id]
if !ok {
return types.MemberProfile{}, types.ErrMemberDoesNotExist
}
return entry.profile, nil
}
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
profiles := make(map[string]types.MemberProfile)
i := 0
for id, entry := range provider.entries {
if i >= offset && (limit == 0 || i < offset+limit) {
profiles[id] = entry.profile
}
i = i + 1
}
return profiles, nil
}
func (provider *MemberProviderCtx) Delete(id string) error {
_, ok := provider.entries[id]
if !ok {
return types.ErrMemberDoesNotExist
}
delete(provider.entries, id)
return nil
}

View file

@ -0,0 +1,24 @@
package object
import (
"github.com/demodesk/neko/pkg/types"
)
type memberEntry struct {
password string
profile types.MemberProfile
}
func (m *memberEntry) CheckPassword(password string) bool {
return m.password == password
}
type User struct {
Username string
Password string
Profile types.MemberProfile
}
type Config struct {
Users []User
}

View file

@ -0,0 +1,133 @@
package plugins
import (
"fmt"
"github.com/rs/zerolog"
"github.com/demodesk/neko/pkg/types"
)
type dependency struct {
plugin types.Plugin
dependsOn []*dependency
invoked bool
logger zerolog.Logger
}
func (a *dependency) findPlugin(name string) (*dependency, bool) {
if a == nil {
return nil, false
}
if a.plugin.Name() == name {
return a, true
}
for _, dep := range a.dependsOn {
plug, ok := dep.findPlugin(name)
if ok {
return plug, true
}
}
return nil, false
}
func (a *dependency) startPlugin(pm types.PluginManagers) error {
if a.invoked {
return nil
}
a.invoked = true
for _, do := range a.dependsOn {
if err := do.startPlugin(pm); err != nil {
return fmt.Errorf("plugin's '%s' dependency: %w", a.plugin.Name(), err)
}
}
err := a.plugin.Start(pm)
if err != nil {
return fmt.Errorf("plugin '%s' failed to start: %w", a.plugin.Name(), err)
}
a.logger.Info().Str("plugin", a.plugin.Name()).Msg("plugin started")
return nil
}
type dependiencies struct {
deps map[string]*dependency
logger zerolog.Logger
}
func (d *dependiencies) addPlugin(plugin types.Plugin) error {
pluginName := plugin.Name()
plug, ok := d.deps[pluginName]
if !ok {
plug = &dependency{}
} else if plug.plugin != nil {
return fmt.Errorf("plugin '%s' already added", pluginName)
}
plug.plugin = plugin
plug.logger = d.logger
d.deps[pluginName] = plug
dplug, ok := plugin.(types.DependablePlugin)
if !ok {
return nil
}
for _, depName := range dplug.DependsOn() {
dependsOn, ok := d.deps[depName]
if !ok {
dependsOn = &dependency{}
} else if dependsOn.plugin != nil {
// if there is a cyclical dependency, break it and return error
if tdep, ok := dependsOn.findPlugin(pluginName); ok {
dependsOn.dependsOn = nil
delete(d.deps, pluginName)
return fmt.Errorf("cyclical dependency detected: '%s' <-> '%s'", pluginName, tdep.plugin.Name())
}
}
plug.dependsOn = append(plug.dependsOn, dependsOn)
d.deps[depName] = dependsOn
}
return nil
}
func (d *dependiencies) findPlugin(name string) (*dependency, bool) {
for _, dep := range d.deps {
plug, ok := dep.findPlugin(name)
if ok {
return plug, true
}
}
return nil, false
}
func (d *dependiencies) start(pm types.PluginManagers) error {
for _, dep := range d.deps {
if err := dep.startPlugin(pm); err != nil {
return err
}
}
return nil
}
func (d *dependiencies) forEach(f func(*dependency) error) error {
for _, dep := range d.deps {
if err := f(dep); err != nil {
return err
}
}
return nil
}
func (d *dependiencies) len() int {
return len(d.deps)
}

View file

@ -0,0 +1,630 @@
package plugins
import (
"reflect"
"testing"
"github.com/demodesk/neko/pkg/types"
)
func Test_deps_addPlugin(t *testing.T) {
type args struct {
p []types.Plugin
}
tests := []struct {
name string
args args
want map[string]*dependency
skipRun bool
wantErr1 bool
wantErr2 bool
}{
{
name: "three plugins - no dependencies",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "first"},
&dummyPlugin{name: "second"},
&dummyPlugin{name: "third"},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
"second": {
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
"third": {
plugin: &dummyPlugin{name: "third", idx: 0},
invoked: true,
dependsOn: nil,
},
},
}, {
name: "three plugins - one dependency",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "third", dep: []string{"second"}},
&dummyPlugin{name: "first"},
&dummyPlugin{name: "second"},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
"second": {
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
"third": {
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
}, {
name: "three plugins - one double dependency",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "third", dep: []string{"first", "second"}},
&dummyPlugin{name: "first"},
&dummyPlugin{name: "second"},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
"second": {
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
"third": {
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
{
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
}, {
name: "three plugins - two dependencies",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "third", dep: []string{"first"}},
&dummyPlugin{name: "first"},
&dummyPlugin{name: "second", dep: []string{"first"}},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first"},
invoked: false,
dependsOn: nil,
},
"third": {
plugin: &dummyPlugin{name: "third", dep: []string{"first"}},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first"},
invoked: false,
dependsOn: nil,
},
},
},
"second": {
plugin: &dummyPlugin{name: "second", dep: []string{"first"}},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first"},
invoked: false,
dependsOn: nil,
},
},
},
},
skipRun: true,
}, {
name: "three plugins - three dependencies",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "third", dep: []string{"second"}},
&dummyPlugin{name: "first"},
&dummyPlugin{name: "second", dep: []string{"first"}},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
"second": {
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
"third": {
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 2},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
},
},
}, {
name: "four plugins - added in reverse order, with dependencies",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "forth", dep: []string{"third"}},
&dummyPlugin{name: "third", dep: []string{"second"}},
&dummyPlugin{name: "second", dep: []string{"first"}},
&dummyPlugin{name: "first"},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: false,
dependsOn: nil,
},
"second": {
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: false,
dependsOn: nil,
},
},
},
"third": {
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: false,
dependsOn: nil,
},
},
},
},
},
"forth": {
plugin: &dummyPlugin{name: "forth", dep: []string{"third"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: false,
dependsOn: nil,
},
},
},
},
},
},
},
},
skipRun: true,
}, {
name: "four plugins - two double dependencies",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "forth", dep: []string{"first", "third"}},
&dummyPlugin{name: "third", dep: []string{"first", "second"}},
&dummyPlugin{name: "second"},
&dummyPlugin{name: "first"},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
"second": {
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
"third": {
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
{
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
"forth": {
plugin: &dummyPlugin{name: "forth", dep: []string{"first", "third"}, idx: 2},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
{
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", idx: 0},
invoked: true,
dependsOn: nil,
},
{
plugin: &dummyPlugin{name: "second", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
},
},
}, {
// So, when we have plugin A in the list and want to add plugin C we can't determine the proper order without
// resolving their direct dependiencies first:
//
// Can be C->D->A->B if D depends on A
//
// So to do it properly I would imagine tht we need to resolve all direct dependiencies first and build multiple lists:
//
// i.e. A->B->C D F->G
//
// and then join these lists in any order.
name: "add indirect dependency CDAB",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "A", dep: []string{"B"}},
&dummyPlugin{name: "C", dep: []string{"D"}},
&dummyPlugin{name: "B"},
&dummyPlugin{name: "D", dep: []string{"A"}},
},
},
want: map[string]*dependency{
"B": {
plugin: &dummyPlugin{name: "B", idx: 0},
invoked: true,
dependsOn: nil,
},
"A": {
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "B", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
"D": {
plugin: &dummyPlugin{name: "D", dep: []string{"A"}, idx: 2},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "B", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
},
"C": {
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 3},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "D", dep: []string{"A"}, idx: 2},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "B", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
},
},
},
},
}, {
// So, when we have plugin A in the list and want to add plugin C we can't determine the proper order without
// resolving their direct dependiencies first:
//
// Can be A->B->C->D (in this test) if B depends on C
//
// So to do it properly I would imagine tht we need to resolve all direct dependiencies first and build multiple lists:
//
// i.e. A->B->C D F->G
//
// and then join these lists in any order.
name: "add indirect dependency ABCD",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "C", dep: []string{"D"}},
&dummyPlugin{name: "D"},
&dummyPlugin{name: "B", dep: []string{"C"}},
&dummyPlugin{name: "A", dep: []string{"B"}},
},
},
want: map[string]*dependency{
"D": {
plugin: &dummyPlugin{name: "D", idx: 0},
invoked: true,
dependsOn: nil,
},
"C": {
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "D", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
"B": {
plugin: &dummyPlugin{name: "B", dep: []string{"C"}, idx: 2},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "D", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
},
"A": {
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 3},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "B", dep: []string{"C"}, idx: 2},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
invoked: true,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "D", idx: 0},
invoked: true,
dependsOn: nil,
},
},
},
},
},
},
},
},
}, {
name: "add duplicate plugin",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "first"},
&dummyPlugin{name: "first"},
},
},
want: map[string]*dependency{
"first": {plugin: &dummyPlugin{name: "first", idx: 0}, invoked: true},
},
wantErr1: true,
}, {
name: "cyclical dependency",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "first", dep: []string{"second"}},
&dummyPlugin{name: "second", dep: []string{"first"}},
},
},
want: map[string]*dependency{
"first": {
plugin: &dummyPlugin{name: "first", dep: []string{"second"}, idx: 1},
invoked: true,
},
},
wantErr1: true,
}, {
name: "four plugins - cyclical transitive dependencies in reverse order",
args: args{
p: []types.Plugin{
&dummyPlugin{name: "forth", dep: []string{"third"}},
&dummyPlugin{name: "third", dep: []string{"second"}},
&dummyPlugin{name: "second", dep: []string{"first"}},
&dummyPlugin{name: "first", dep: []string{"forth"}},
},
},
want: map[string]*dependency{
"second": {
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", dep: []string{"forth"}, idx: 0},
invoked: false,
dependsOn: nil,
},
},
},
"third": {
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
invoked: false,
dependsOn: []*dependency{
{
plugin: &dummyPlugin{name: "first", dep: []string{"forth"}, idx: 0},
invoked: false,
dependsOn: nil,
},
},
},
},
},
"forth": {
plugin: &dummyPlugin{name: "forth", dep: []string{"third"}, idx: 0},
invoked: false,
dependsOn: nil,
},
},
wantErr1: true,
skipRun: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := &dependiencies{deps: make(map[string]*dependency)}
var (
err error
counter int
)
for _, p := range tt.args.p {
if !tt.skipRun {
p.(*dummyPlugin).counter = &counter
}
if err = d.addPlugin(p); err != nil {
break
}
}
if err != nil != tt.wantErr1 {
t.Errorf("dependiencies.addPlugin() error = %v, wantErr1 %v", err, tt.wantErr1)
return
}
if !tt.skipRun {
if err := d.start(types.PluginManagers{}); (err != nil) != tt.wantErr2 {
t.Errorf("dependiencies.start() error = %v, wantErr1 %v", err, tt.wantErr2)
}
}
if !reflect.DeepEqual(d.deps, tt.want) {
t.Errorf("deps = %v, want %v", d.deps, tt.want)
}
})
}
}
type dummyPlugin struct {
name string
dep []string
idx int
counter *int
}
func (d dummyPlugin) Name() string {
return d.name
}
func (d dummyPlugin) DependsOn() []string {
return d.dep
}
func (d dummyPlugin) Config() types.PluginConfig {
return nil
}
func (d *dummyPlugin) Start(types.PluginManagers) error {
if len(d.dep) > 0 {
*d.counter++
d.idx = *d.counter
}
d.counter = nil
return nil
}
func (d dummyPlugin) Shutdown() error {
return nil
}

177
internal/plugins/manager.go Normal file
View file

@ -0,0 +1,177 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"plugin"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/pkg/types"
)
type ManagerCtx struct {
logger zerolog.Logger
config *config.Plugins
plugins dependiencies
}
func New(config *config.Plugins) *ManagerCtx {
manager := &ManagerCtx{
logger: log.With().Str("module", "plugins").Logger(),
config: config,
plugins: dependiencies{
deps: make(map[string]*dependency),
},
}
manager.plugins.logger = manager.logger
if config.Enabled {
err := manager.loadDir(config.Dir)
// only log error if plugin is not required
if err != nil && config.Required {
manager.logger.Fatal().Err(err).Msg("error loading plugins")
}
manager.logger.Info().Msgf("loading finished, total %d plugins", manager.plugins.len())
}
return manager
}
func (manager *ManagerCtx) loadDir(dir string) error {
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
err = manager.load(path)
// return error if plugin is required
if err != nil && manager.config.Required {
return err
}
// otherwise only log error if plugin is not required
manager.logger.Err(err).Str("plugin", path).Msg("loading a plugin")
return nil
})
}
func (manager *ManagerCtx) load(path string) error {
pl, err := plugin.Open(path)
if err != nil {
return err
}
sym, err := pl.Lookup("Plugin")
if err != nil {
return err
}
p, ok := sym.(types.Plugin)
if !ok {
return fmt.Errorf("not a valid plugin")
}
if err = manager.plugins.addPlugin(p); err != nil {
return fmt.Errorf("failed to add plugin: %w", err)
}
return nil
}
func (manager *ManagerCtx) InitConfigs(cmd *cobra.Command) {
_ = manager.plugins.forEach(func(d *dependency) error {
if err := d.plugin.Config().Init(cmd); err != nil {
log.Err(err).Str("plugin", d.plugin.Name()).Msg("unable to initialize configuration")
}
return nil
})
}
func (manager *ManagerCtx) SetConfigs() {
_ = manager.plugins.forEach(func(d *dependency) error {
d.plugin.Config().Set()
return nil
})
}
func (manager *ManagerCtx) Start(
sessionManager types.SessionManager,
webSocketManager types.WebSocketManager,
apiManager types.ApiManager,
) {
err := manager.plugins.start(types.PluginManagers{
SessionManager: sessionManager,
WebSocketManager: webSocketManager,
ApiManager: apiManager,
LoadServiceFromPlugin: manager.LookupService,
})
if err != nil {
if manager.config.Required {
manager.logger.Fatal().Err(err).Msg("failed to start plugins, exiting...")
} else {
manager.logger.Err(err).Msg("failed to start plugins, skipping...")
}
}
}
func (manager *ManagerCtx) Shutdown() error {
_ = manager.plugins.forEach(func(d *dependency) error {
err := d.plugin.Shutdown()
manager.logger.Err(err).Str("plugin", d.plugin.Name()).Msg("plugin shutdown")
return nil
})
return nil
}
func (manager *ManagerCtx) LookupService(pluginName string) (any, error) {
plug, ok := manager.plugins.findPlugin(pluginName)
if !ok {
return nil, fmt.Errorf("plugin '%s' not found", pluginName)
}
expPlug, ok := plug.plugin.(types.ExposablePlugin)
if !ok {
return nil, fmt.Errorf("plugin '%s' is not exposable", pluginName)
}
return expPlug.ExposeService(), nil
}
func (manager *ManagerCtx) Metadata() []types.PluginMetadata {
var plugins []types.PluginMetadata
_ = manager.plugins.forEach(func(d *dependency) error {
dependsOn := make([]string, 0)
deps, isDependalbe := d.plugin.(types.DependablePlugin)
if isDependalbe {
dependsOn = deps.DependsOn()
}
_, isExposable := d.plugin.(types.ExposablePlugin)
plugins = append(plugins, types.PluginMetadata{
Name: d.plugin.Name(),
IsDependable: isDependalbe,
IsExposable: isExposable,
DependsOn: dependsOn,
})
return nil
})
return plugins
}

80
internal/session/auth.go Normal file
View file

@ -0,0 +1,80 @@
package session
import (
"errors"
"net/http"
"strings"
"time"
"github.com/demodesk/neko/pkg/types"
)
func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token string) {
sameSite := http.SameSiteDefaultMode
if manager.config.CookieSecure {
sameSite = http.SameSiteNoneMode
}
http.SetCookie(w, &http.Cookie{
Name: manager.config.CookieName,
Value: token,
Expires: time.Now().Add(manager.config.CookieExpiration),
Secure: manager.config.CookieSecure,
SameSite: sameSite,
HttpOnly: true,
})
}
func (manager *SessionManagerCtx) CookieClearToken(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(manager.config.CookieName)
if err != nil {
return
}
cookie.Value = ""
cookie.Expires = time.Unix(0, 0)
http.SetCookie(w, cookie)
}
func (manager *SessionManagerCtx) Authenticate(r *http.Request) (types.Session, error) {
token, ok := manager.getToken(r)
if !ok {
return nil, errors.New("no authentication provided")
}
session, ok := manager.GetByToken(token)
if !ok {
return nil, types.ErrSessionNotFound
}
if !session.Profile().CanLogin {
return nil, types.ErrSessionLoginDisabled
}
return session, nil
}
func (manager *SessionManagerCtx) getToken(r *http.Request) (string, bool) {
if manager.CookieEnabled() {
// get from Cookie
cookie, err := r.Cookie(manager.config.CookieName)
if err == nil {
return cookie.Value, true
}
}
// get from Header
reqToken := r.Header.Get("Authorization")
splitToken := strings.Split(reqToken, "Bearer ")
if len(splitToken) == 2 {
return strings.TrimSpace(splitToken[1]), true
}
// get from URL
token := r.URL.Query().Get("token")
if token != "" {
return token, true
}
return "", false
}

412
internal/session/manager.go Normal file
View file

@ -0,0 +1,412 @@
package session
import (
"errors"
"sync"
"sync/atomic"
"github.com/kataras/go-events"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
func New(config *config.Session) *SessionManagerCtx {
manager := &SessionManagerCtx{
logger: log.With().Str("module", "session").Logger(),
config: config,
settings: types.Settings{
PrivateMode: config.PrivateMode,
LockedControls: config.LockedControls,
ImplicitHosting: config.ImplicitHosting,
InactiveCursors: config.InactiveCursors,
MercifulReconnect: config.MercifulReconnect,
},
tokens: make(map[string]string),
sessions: make(map[string]*SessionCtx),
cursors: make(map[types.Session][]types.Cursor),
emmiter: events.New(),
}
// create API session
if config.APIToken != "" {
manager.apiSession = &SessionCtx{
id: "API",
token: config.APIToken,
manager: manager,
logger: manager.logger.With().Str("session_id", "API").Logger(),
profile: types.MemberProfile{
Name: "API Session",
IsAdmin: true,
CanLogin: true,
CanConnect: false,
CanWatch: true,
CanHost: true,
CanAccessClipboard: true,
},
}
}
// try to load sessions from file
manager.load()
return manager
}
type SessionManagerCtx struct {
logger zerolog.Logger
config *config.Session
settings types.Settings
settingsMu sync.Mutex
tokens map[string]string
sessions map[string]*SessionCtx
sessionsMu sync.Mutex
hostId atomic.Value
cursors map[types.Session][]types.Cursor
cursorsMu sync.Mutex
emmiter events.EventEmmiter
apiSession *SessionCtx
}
func (manager *SessionManagerCtx) Create(id string, profile types.MemberProfile) (types.Session, string, error) {
token, err := utils.NewUID(64)
if err != nil {
return nil, "", err
}
manager.sessionsMu.Lock()
if _, ok := manager.sessions[id]; ok {
manager.sessionsMu.Unlock()
return nil, "", types.ErrSessionAlreadyExists
}
if _, ok := manager.tokens[token]; ok {
manager.sessionsMu.Unlock()
return nil, "", errors.New("session token already exists")
}
session := &SessionCtx{
id: id,
token: token,
manager: manager,
logger: manager.logger.With().Str("session_id", id).Logger(),
profile: profile,
}
manager.tokens[token] = id
manager.sessions[id] = session
manager.sessionsMu.Unlock()
manager.emmiter.Emit("created", session)
manager.save()
return session, token, nil
}
func (manager *SessionManagerCtx) Update(id string, profile types.MemberProfile) error {
manager.sessionsMu.Lock()
session, ok := manager.sessions[id]
if !ok {
manager.sessionsMu.Unlock()
return types.ErrSessionNotFound
}
session.profile = profile
manager.sessionsMu.Unlock()
manager.emmiter.Emit("profile_changed", session)
manager.save()
session.profileChanged()
return nil
}
func (manager *SessionManagerCtx) Delete(id string) error {
manager.sessionsMu.Lock()
session, ok := manager.sessions[id]
if !ok {
manager.sessionsMu.Unlock()
return types.ErrSessionNotFound
}
delete(manager.tokens, session.token)
delete(manager.sessions, id)
manager.sessionsMu.Unlock()
if session.State().IsConnected {
session.DestroyWebSocketPeer("session deleted")
}
if session.State().IsWatching {
session.GetWebRTCPeer().Destroy()
}
manager.emmiter.Emit("deleted", session)
manager.save()
return nil
}
func (manager *SessionManagerCtx) Get(id string) (types.Session, bool) {
manager.sessionsMu.Lock()
defer manager.sessionsMu.Unlock()
session, ok := manager.sessions[id]
return session, ok
}
func (manager *SessionManagerCtx) GetByToken(token string) (types.Session, bool) {
manager.sessionsMu.Lock()
id, ok := manager.tokens[token]
manager.sessionsMu.Unlock()
if ok {
return manager.Get(id)
}
// is API session
if manager.apiSession != nil && manager.apiSession.token == token {
return manager.apiSession, true
}
return nil, false
}
func (manager *SessionManagerCtx) List() []types.Session {
manager.sessionsMu.Lock()
defer manager.sessionsMu.Unlock()
var sessions []types.Session
for _, session := range manager.sessions {
sessions = append(sessions, session)
}
return sessions
}
// ---
// host
// ---
func (manager *SessionManagerCtx) SetHost(host types.Session) {
var hostId string
if host != nil {
hostId = host.ID()
}
manager.hostId.Store(hostId)
manager.emmiter.Emit("host_changed", host)
}
func (manager *SessionManagerCtx) GetHost() (types.Session, bool) {
hostId, ok := manager.hostId.Load().(string)
if !ok || hostId == "" {
return nil, false
}
return manager.Get(hostId)
}
func (manager *SessionManagerCtx) ClearHost() {
manager.SetHost(nil)
}
func (manager *SessionManagerCtx) isHost(host types.Session) bool {
hostId, ok := manager.hostId.Load().(string)
return ok && hostId == host.ID()
}
// ---
// cursors
// ---
func (manager *SessionManagerCtx) SetCursor(cursor types.Cursor, session types.Session) {
manager.cursorsMu.Lock()
defer manager.cursorsMu.Unlock()
list, ok := manager.cursors[session]
if !ok {
list = []types.Cursor{}
}
list = append(list, cursor)
manager.cursors[session] = list
}
func (manager *SessionManagerCtx) PopCursors() map[types.Session][]types.Cursor {
manager.cursorsMu.Lock()
defer manager.cursorsMu.Unlock()
cursors := manager.cursors
manager.cursors = make(map[types.Session][]types.Cursor)
return cursors
}
// ---
// broadcasts
// ---
func (manager *SessionManagerCtx) Broadcast(event string, payload any, exclude ...string) {
for _, session := range manager.List() {
if !session.State().IsConnected {
continue
}
if len(exclude) > 0 {
if in, _ := utils.ArrayIn(session.ID(), exclude); in {
continue
}
}
session.Send(event, payload)
}
}
func (manager *SessionManagerCtx) AdminBroadcast(event string, payload any, exclude ...string) {
for _, session := range manager.List() {
if !session.State().IsConnected || !session.Profile().IsAdmin {
continue
}
if len(exclude) > 0 {
if in, _ := utils.ArrayIn(session.ID(), exclude); in {
continue
}
}
session.Send(event, payload)
}
}
func (manager *SessionManagerCtx) InactiveCursorsBroadcast(event string, payload any, exclude ...string) {
for _, session := range manager.List() {
if !session.State().IsConnected || !session.Profile().CanSeeInactiveCursors {
continue
}
if len(exclude) > 0 {
if in, _ := utils.ArrayIn(session.ID(), exclude); in {
continue
}
}
session.Send(event, payload)
}
}
// ---
// events
// ---
func (manager *SessionManagerCtx) OnCreated(listener func(session types.Session)) {
manager.emmiter.On("created", func(payload ...any) {
listener(payload[0].(*SessionCtx))
})
}
func (manager *SessionManagerCtx) OnDeleted(listener func(session types.Session)) {
manager.emmiter.On("deleted", func(payload ...any) {
listener(payload[0].(*SessionCtx))
})
}
func (manager *SessionManagerCtx) OnConnected(listener func(session types.Session)) {
manager.emmiter.On("connected", func(payload ...any) {
listener(payload[0].(*SessionCtx))
})
}
func (manager *SessionManagerCtx) OnDisconnected(listener func(session types.Session)) {
manager.emmiter.On("disconnected", func(payload ...any) {
listener(payload[0].(*SessionCtx))
})
}
func (manager *SessionManagerCtx) OnProfileChanged(listener func(session types.Session)) {
manager.emmiter.On("profile_changed", func(payload ...any) {
listener(payload[0].(*SessionCtx))
})
}
func (manager *SessionManagerCtx) OnStateChanged(listener func(session types.Session)) {
manager.emmiter.On("state_changed", func(payload ...any) {
listener(payload[0].(*SessionCtx))
})
}
func (manager *SessionManagerCtx) OnHostChanged(listener func(session types.Session)) {
manager.emmiter.On("host_changed", func(payload ...any) {
if payload[0] == nil {
listener(nil)
} else {
listener(payload[0].(*SessionCtx))
}
})
}
func (manager *SessionManagerCtx) OnSettingsChanged(listener func(new types.Settings, old types.Settings)) {
manager.emmiter.On("settings_changed", func(payload ...any) {
listener(payload[0].(types.Settings), payload[1].(types.Settings))
})
}
// ---
// settings
// ---
func (manager *SessionManagerCtx) UpdateSettings(new types.Settings) {
manager.settingsMu.Lock()
old := manager.settings
manager.settings = new
manager.settingsMu.Unlock()
// if private mode changed
if old.PrivateMode != new.PrivateMode {
// update webrtc paused state for all sessions
for _, session := range manager.List() {
enabled := session.PrivateModeEnabled()
// if session had control, it must release it
if enabled && session.IsHost() {
manager.ClearHost()
}
// its webrtc connection will be paused or unpaused
if webrtcPeer := session.GetWebRTCPeer(); webrtcPeer != nil {
webrtcPeer.SetPaused(enabled)
}
}
}
// if contols have been locked
if old.LockedControls != new.LockedControls && new.LockedControls {
// if the host is not admin, it must release controls
host, hasHost := manager.GetHost()
if hasHost && !host.Profile().IsAdmin {
manager.ClearHost()
}
}
manager.emmiter.Emit("settings_changed", new, old)
}
func (manager *SessionManagerCtx) Settings() types.Settings {
manager.settingsMu.Lock()
defer manager.settingsMu.Unlock()
return manager.settings
}
func (manager *SessionManagerCtx) CookieEnabled() bool {
return manager.config.CookieEnabled
}

View file

@ -0,0 +1,97 @@
package session
import (
"encoding/json"
"errors"
"os"
"github.com/demodesk/neko/pkg/types"
)
func (manager *SessionManagerCtx) save() {
if manager.config.File == "" {
return
}
// serialize sessions
sessions := make([]types.SessionProfile, 0, len(manager.sessions))
for _, session := range manager.sessions {
sessions = append(sessions, types.SessionProfile{
Id: session.id,
Token: session.token,
Profile: session.profile,
})
}
// convert to json
data, err := json.Marshal(sessions)
if err != nil {
manager.logger.Error().Err(err).Msg("failed to marshal sessions")
return
}
// write to file
err = os.WriteFile(manager.config.File, data, 0644)
if err != nil {
manager.logger.Error().Err(err).
Str("file", manager.config.File).
Msg("failed to write sessions to a file")
}
}
func (manager *SessionManagerCtx) load() {
if manager.config.File == "" {
return
}
// read file
data, err := os.ReadFile(manager.config.File)
if err != nil {
// if file does not exist
if errors.Is(err, os.ErrNotExist) {
manager.logger.Info().
Str("file", manager.config.File).
Msg("sessions file does not exist")
return
}
manager.logger.Error().Err(err).
Str("file", manager.config.File).
Msg("failed to read sessions from a file")
return
}
// if file is empty
if len(data) == 0 {
manager.logger.Info().
Str("file", manager.config.File).
Msg("sessions file is empty")
return
}
// deserialize sessions
sessions := make([]types.SessionProfile, 0)
err = json.Unmarshal(data, &sessions)
if err != nil {
manager.logger.Error().Err(err).Msg("failed to unmarshal sessions")
return
}
// create sessions
manager.sessionsMu.Lock()
for _, session := range sessions {
manager.tokens[session.Token] = session.Id
manager.sessions[session.Id] = &SessionCtx{
id: session.Id,
token: session.Token,
manager: manager,
logger: manager.logger.With().Str("session_id", session.Id).Logger(),
profile: session.Profile,
}
}
manager.sessionsMu.Unlock()
manager.logger.Info().
Int("sessions", len(sessions)).
Str("file", manager.config.File).
Msg("loaded sessions from a file")
}

285
internal/session/session.go Normal file
View file

@ -0,0 +1,285 @@
package session
import (
"sync"
"time"
"github.com/rs/zerolog"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/event"
)
// client is expected to reconnect within 5 second
// if some unexpected websocket disconnect happens
const WS_DELAYED_DURATION = 5 * time.Second
type SessionCtx struct {
id string
token string
logger zerolog.Logger
manager *SessionManagerCtx
profile types.MemberProfile
state types.SessionState
websocketPeer types.WebSocketPeer
websocketMu sync.Mutex
// websocket delayed set connected events
wsDelayedMu sync.Mutex
wsDelayedTimer *time.Timer
webrtcPeer types.WebRTCPeer
webrtcMu sync.Mutex
}
func (session *SessionCtx) ID() string {
return session.id
}
func (session *SessionCtx) Profile() types.MemberProfile {
return session.profile
}
func (session *SessionCtx) profileChanged() {
if !session.profile.CanHost && session.IsHost() {
session.manager.ClearHost()
}
if (!session.profile.CanConnect || !session.profile.CanLogin || !session.profile.CanWatch) && session.state.IsWatching {
session.GetWebRTCPeer().Destroy()
}
if (!session.profile.CanConnect || !session.profile.CanLogin) && session.state.IsConnected {
session.DestroyWebSocketPeer("profile changed")
}
// update webrtc paused state
if webrtcPeer := session.GetWebRTCPeer(); webrtcPeer != nil {
webrtcPeer.SetPaused(session.PrivateModeEnabled())
}
}
func (session *SessionCtx) State() types.SessionState {
return session.state
}
func (session *SessionCtx) IsHost() bool {
return session.manager.isHost(session)
}
func (session *SessionCtx) PrivateModeEnabled() bool {
return session.manager.Settings().PrivateMode && !session.profile.IsAdmin
}
func (session *SessionCtx) SetCursor(cursor types.Cursor) {
if session.manager.Settings().InactiveCursors && session.profile.SendsInactiveCursor {
session.manager.SetCursor(cursor, session)
}
}
// ---
// websocket
// ---
//
// Connect WebSocket peer sets current peer and emits connected event. It also destroys the
// previous peer, if there was one. If the peer is already set, it will be ignored.
//
func (session *SessionCtx) ConnectWebSocketPeer(websocketPeer types.WebSocketPeer) {
session.websocketMu.Lock()
isCurrentPeer := websocketPeer == session.websocketPeer
session.websocketPeer, websocketPeer = websocketPeer, session.websocketPeer
session.websocketMu.Unlock()
// ignore if already set
if isCurrentPeer {
return
}
session.logger.Info().Msg("set websocket connected")
// update state
now := time.Now()
session.state.IsConnected = true
session.state.ConnectedSince = &now
session.state.NotConnectedSince = nil
session.manager.emmiter.Emit("connected", session)
// if there is a previous peer, destroy it
if websocketPeer != nil {
websocketPeer.Destroy("connection replaced")
}
}
//
// Disconnect WebSocket peer sets current peer to nil and emits disconnected event. It also
// allows for a delayed disconnect. That means, the peer will not be disconnected immediately,
// but after a delay. If the peer is connected again before the delay, the disconnect will be
// cancelled.
//
// If the peer is not the current peer or the peer is nil, it will be ignored.
//
func (session *SessionCtx) DisconnectWebSocketPeer(websocketPeer types.WebSocketPeer, delayed bool) {
session.websocketMu.Lock()
isCurrentPeer := websocketPeer == session.websocketPeer && websocketPeer != nil
session.websocketMu.Unlock()
// ignore if not current peer
if !isCurrentPeer {
return
}
//
// ws delayed
//
var wsDelayedTimer *time.Timer
if delayed {
wsDelayedTimer = time.AfterFunc(WS_DELAYED_DURATION, func() {
session.DisconnectWebSocketPeer(websocketPeer, false)
})
}
session.wsDelayedMu.Lock()
if session.wsDelayedTimer != nil {
session.wsDelayedTimer.Stop()
}
session.wsDelayedTimer = wsDelayedTimer
session.wsDelayedMu.Unlock()
if delayed {
session.logger.Info().Msg("delayed websocket disconnected")
return
}
//
// not delayed
//
session.logger.Info().Msg("set websocket disconnected")
now := time.Now()
session.state.IsConnected = false
session.state.ConnectedSince = nil
session.state.NotConnectedSince = &now
session.manager.emmiter.Emit("disconnected", session)
session.websocketMu.Lock()
if websocketPeer == session.websocketPeer {
session.websocketPeer = nil
}
session.websocketMu.Unlock()
}
//
// Destroy WebSocket peer disconnects the peer and destroys it. It ensures that the peer is
// disconnected immediately even though normal flow would be to disconnect it delayed.
//
func (session *SessionCtx) DestroyWebSocketPeer(reason string) {
session.websocketMu.Lock()
peer := session.websocketPeer
session.websocketMu.Unlock()
if peer == nil {
return
}
// disconnect peer first, so that it is not used anymore
session.DisconnectWebSocketPeer(peer, false)
// destroy it afterwards
peer.Destroy(reason)
}
//
// Send event to websocket peer.
//
func (session *SessionCtx) Send(event string, payload any) {
session.websocketMu.Lock()
peer := session.websocketPeer
session.websocketMu.Unlock()
if peer != nil {
peer.Send(event, payload)
}
}
// ---
// webrtc
// ---
//
// Set webrtc peer and destroy the old one, if there is old one.
//
func (session *SessionCtx) SetWebRTCPeer(webrtcPeer types.WebRTCPeer) {
session.webrtcMu.Lock()
session.webrtcPeer, webrtcPeer = webrtcPeer, session.webrtcPeer
session.webrtcMu.Unlock()
if webrtcPeer != nil && webrtcPeer != session.webrtcPeer {
webrtcPeer.Destroy()
}
}
//
// Set if current webrtc peer is connected or not. Since there might be lefover calls from
// webrtc peer, that are not used anymore, we need to check if the webrtc peer is still the
// same as the one we are setting the connected state for.
//
// If webrtc peer is disconnected, we don't expect it to be reconnected, so we set it to nil
// and send a signal close to the client. New connection is expected to use a new webrtc peer.
//
func (session *SessionCtx) SetWebRTCConnected(webrtcPeer types.WebRTCPeer, connected bool) {
session.webrtcMu.Lock()
isCurrentPeer := webrtcPeer == session.webrtcPeer
session.webrtcMu.Unlock()
if !isCurrentPeer {
return
}
session.logger.Info().
Bool("connected", connected).
Msg("set webrtc connected")
// update state
session.state.IsWatching = connected
if now := time.Now(); connected {
session.state.WatchingSince = &now
session.state.NotWatchingSince = nil
} else {
session.state.WatchingSince = nil
session.state.NotWatchingSince = &now
}
session.manager.emmiter.Emit("state_changed", session)
if connected {
return
}
session.webrtcMu.Lock()
isCurrentPeer = webrtcPeer == session.webrtcPeer
if isCurrentPeer {
session.webrtcPeer = nil
}
session.webrtcMu.Unlock()
if isCurrentPeer {
session.Send(event.SIGNAL_CLOSE, nil)
}
}
//
// Get current WebRTC peer. Nil if not connected.
//
func (session *SessionCtx) GetWebRTCPeer() types.WebRTCPeer {
session.webrtcMu.Lock()
defer session.webrtcMu.Unlock()
return session.webrtcPeer
}

View file

@ -0,0 +1,168 @@
package cursor
import (
"reflect"
"sync"
"github.com/rs/zerolog"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type ImageListener interface {
SendCursorImage(cur *types.CursorImage, img []byte) error
}
type Image interface {
Start()
Shutdown()
GetCurrent() (cur *types.CursorImage, img []byte, err error)
AddListener(listener ImageListener)
RemoveListener(listener ImageListener)
}
type imageEntry struct {
*types.CursorImage
ImagePNG []byte
}
type image struct {
logger zerolog.Logger
desktop types.DesktopManager
listeners map[uintptr]ImageListener
listenersMu sync.RWMutex
cache map[uint64]*imageEntry
cacheMu sync.RWMutex
current *imageEntry
maxSerial uint64
}
func NewImage(logger zerolog.Logger, desktop types.DesktopManager) *image {
return &image{
logger: logger.With().Str("submodule", "cursor-image").Logger(),
desktop: desktop,
listeners: map[uintptr]ImageListener{},
cache: map[uint64]*imageEntry{},
maxSerial: 300, // TODO: Cleanup?
}
}
func (manager *image) Start() {
manager.desktop.OnCursorChanged(func(serial uint64) {
entry, err := manager.getCached(serial)
if err != nil {
manager.logger.Err(err).Msg("failed to get cursor image")
return
}
manager.current = entry
manager.listenersMu.RLock()
for _, l := range manager.listeners {
if err := l.SendCursorImage(entry.CursorImage, entry.ImagePNG); err != nil {
manager.logger.Err(err).Msg("failed to set cursor image")
}
}
manager.listenersMu.RUnlock()
})
manager.logger.Info().Msg("starting")
}
func (manager *image) Shutdown() {
manager.logger.Info().Msg("shutdown")
manager.listenersMu.Lock()
for key := range manager.listeners {
delete(manager.listeners, key)
}
manager.listenersMu.Unlock()
}
func (manager *image) getCached(serial uint64) (*imageEntry, error) {
// zero means no serial available
if serial == 0 || serial > manager.maxSerial {
manager.logger.Debug().Uint64("serial", serial).Msg("cache bypass")
return manager.fetchEntry()
}
manager.cacheMu.RLock()
entry, ok := manager.cache[serial]
manager.cacheMu.RUnlock()
if ok {
return entry, nil
}
manager.logger.Debug().Uint64("serial", serial).Msg("cache miss")
entry, err := manager.fetchEntry()
if err != nil {
return nil, err
}
manager.cacheMu.Lock()
manager.cache[entry.Serial] = entry
manager.cacheMu.Unlock()
if entry.Serial != serial {
manager.logger.Warn().
Uint64("expected-serial", serial).
Uint64("received-serial", entry.Serial).
Msg("serial mismatch")
}
return entry, nil
}
func (manager *image) GetCurrent() (cur *types.CursorImage, img []byte, err error) {
if manager.current != nil {
return manager.current.CursorImage, manager.current.ImagePNG, nil
}
entry, err := manager.fetchEntry()
if err != nil {
return nil, nil, err
}
manager.current = entry
return entry.CursorImage, entry.ImagePNG, nil
}
func (manager *image) AddListener(listener ImageListener) {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
if listener != nil {
ptr := reflect.ValueOf(listener).Pointer()
manager.listeners[ptr] = listener
}
}
func (manager *image) RemoveListener(listener ImageListener) {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
if listener != nil {
ptr := reflect.ValueOf(listener).Pointer()
delete(manager.listeners, ptr)
}
}
func (manager *image) fetchEntry() (*imageEntry, error) {
cur := manager.desktop.GetCursorImage()
img, err := utils.CreatePNGImage(cur.Image)
if err != nil {
return nil, err
}
cur.Image = nil // free memory
return &imageEntry{
CursorImage: cur,
ImagePNG: img,
}, nil
}

View file

@ -0,0 +1,74 @@
package cursor
import (
"reflect"
"sync"
"github.com/rs/zerolog"
)
type PositionListener interface {
SendCursorPosition(x, y int) error
}
type Position interface {
Shutdown()
Set(x, y int)
AddListener(listener PositionListener)
RemoveListener(listener PositionListener)
}
type position struct {
logger zerolog.Logger
listeners map[uintptr]PositionListener
listenersMu sync.RWMutex
}
func NewPosition(logger zerolog.Logger) *position {
return &position{
logger: logger.With().Str("submodule", "cursor-position").Logger(),
listeners: map[uintptr]PositionListener{},
}
}
func (manager *position) Shutdown() {
manager.logger.Info().Msg("shutdown")
manager.listenersMu.Lock()
for key := range manager.listeners {
delete(manager.listeners, key)
}
manager.listenersMu.Unlock()
}
func (manager *position) Set(x, y int) {
manager.listenersMu.RLock()
defer manager.listenersMu.RUnlock()
for _, l := range manager.listeners {
if err := l.SendCursorPosition(x, y); err != nil {
manager.logger.Err(err).Msg("failed to set cursor position")
}
}
}
func (manager *position) AddListener(listener PositionListener) {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
if listener != nil {
ptr := reflect.ValueOf(listener).Pointer()
manager.listeners[ptr] = listener
}
}
func (manager *position) RemoveListener(listener PositionListener) {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
if listener != nil {
ptr := reflect.ValueOf(listener).Pointer()
delete(manager.listeners, ptr)
}
}

205
internal/webrtc/handler.go Normal file
View file

@ -0,0 +1,205 @@
package webrtc
import (
"bytes"
"encoding/binary"
"math"
"time"
"github.com/demodesk/neko/internal/webrtc/payload"
"github.com/demodesk/neko/pkg/types"
"github.com/pion/webrtc/v3"
"github.com/rs/zerolog"
)
func (manager *WebRTCManagerCtx) handle(
logger zerolog.Logger, data []byte,
dataChannel *webrtc.DataChannel,
session types.Session,
) error {
isHost := session.IsHost()
//
// parse header
//
buffer := bytes.NewBuffer(data)
header := &payload.Header{}
if err := binary.Read(buffer, binary.BigEndian, header); err != nil {
return err
}
//
// parse body
//
// handle cursor move event
if header.Event == payload.OP_MOVE {
payload := &payload.Move{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
x, y := int(payload.X), int(payload.Y)
if isHost {
// handle active cursor movement
manager.desktop.Move(x, y)
manager.curPosition.Set(x, y)
} else {
// handle inactive cursor movement
session.SetCursor(types.Cursor{
X: x,
Y: y,
})
}
return nil
} else if header.Event == payload.OP_PING {
ping := &payload.Ping{}
if err := binary.Read(buffer, binary.BigEndian, ping); err != nil {
return err
}
// create pong header
header := payload.Header{
Event: payload.OP_PONG,
Length: 19,
}
// generate server timestamp
serverTs := uint64(time.Now().UnixMilli())
// generate pong payload
pong := payload.Pong{
Ping: *ping,
ServerTs1: uint32(serverTs / math.MaxUint32),
ServerTs2: uint32(serverTs % math.MaxUint32),
}
buffer := &bytes.Buffer{}
if err := binary.Write(buffer, binary.BigEndian, header); err != nil {
return err
}
if err := binary.Write(buffer, binary.BigEndian, pong); err != nil {
return err
}
return dataChannel.Send(buffer.Bytes())
}
// continue only if session is host
if !isHost {
return nil
}
switch header.Event {
case payload.OP_SCROLL:
// TODO: remove this once the client is fixed
if header.Length == 4 {
payload := &payload.Scroll_Old{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
manager.desktop.Scroll(int(payload.X), int(payload.Y), false)
logger.Trace().
Int16("x", payload.X).
Int16("y", payload.Y).
Msg("scroll")
} else {
payload := &payload.Scroll{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
manager.desktop.Scroll(int(payload.DeltaX), int(payload.DeltaY), payload.ControlKey)
logger.Trace().
Int16("deltaX", payload.DeltaX).
Int16("deltaY", payload.DeltaY).
Bool("controlKey", payload.ControlKey).
Msg("scroll")
}
case payload.OP_KEY_DOWN:
payload := &payload.Key{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
if err := manager.desktop.KeyDown(payload.Key); err != nil {
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("key down failed")
} else {
logger.Trace().Uint32("key", payload.Key).Msg("key down")
}
case payload.OP_KEY_UP:
payload := &payload.Key{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
if err := manager.desktop.KeyUp(payload.Key); err != nil {
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("key up failed")
} else {
logger.Trace().Uint32("key", payload.Key).Msg("key up")
}
case payload.OP_BTN_DOWN:
payload := &payload.Key{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
if err := manager.desktop.ButtonDown(payload.Key); err != nil {
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("button down failed")
} else {
logger.Trace().Uint32("key", payload.Key).Msg("button down")
}
case payload.OP_BTN_UP:
payload := &payload.Key{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
if err := manager.desktop.ButtonUp(payload.Key); err != nil {
logger.Warn().Err(err).Uint32("key", payload.Key).Msg("button up failed")
} else {
logger.Trace().Uint32("key", payload.Key).Msg("button up")
}
case payload.OP_TOUCH_BEGIN:
payload := &payload.Touch{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
if err := manager.desktop.TouchBegin(payload.TouchId, int(payload.X), int(payload.Y), payload.Pressure); err != nil {
logger.Warn().Err(err).Uint32("touchId", payload.TouchId).Msg("touch begin failed")
} else {
logger.Trace().Uint32("touchId", payload.TouchId).Msg("touch begin")
}
case payload.OP_TOUCH_UPDATE:
payload := &payload.Touch{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
if err := manager.desktop.TouchUpdate(payload.TouchId, int(payload.X), int(payload.Y), payload.Pressure); err != nil {
logger.Warn().Err(err).Uint32("touchId", payload.TouchId).Msg("touch update failed")
} else {
logger.Trace().Uint32("touchId", payload.TouchId).Msg("touch update")
}
case payload.OP_TOUCH_END:
payload := &payload.Touch{}
if err := binary.Read(buffer, binary.BigEndian, payload); err != nil {
return err
}
if err := manager.desktop.TouchEnd(payload.TouchId, int(payload.X), int(payload.Y), payload.Pressure); err != nil {
logger.Warn().Err(err).Uint32("touchId", payload.TouchId).Msg("touch end failed")
} else {
logger.Trace().Uint32("touchId", payload.TouchId).Msg("touch end")
}
}
return nil
}

576
internal/webrtc/manager.go Normal file
View file

@ -0,0 +1,576 @@
package webrtc
import (
"fmt"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/pion/ice/v2"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/cc"
"github.com/pion/interceptor/pkg/gcc"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/internal/webrtc/cursor"
"github.com/demodesk/neko/internal/webrtc/pionlog"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/codec"
"github.com/demodesk/neko/pkg/types/event"
"github.com/demodesk/neko/pkg/types/message"
"github.com/demodesk/neko/pkg/utils"
)
const (
// size of receiving channel used to buffer incoming TCP packets
tcpReadChanBufferSize = 50
// size of buffer used to buffer outgoing TCP packets. Default is 4MB
tcpWriteBufferSizeInBytes = 4 * 1024 * 1024
// the duration without network activity before a Agent is considered disconnected. Default is 5 Seconds
disconnectedTimeout = 4 * time.Second
// the duration without network activity before a Agent is considered failed after disconnected. Default is 25 Seconds
failedTimeout = 6 * time.Second
// how often the ICE Agent sends extra traffic if there is no activity, if media is flowing no traffic will be sent. Default is 2 seconds
keepAliveInterval = 2 * time.Second
// send a PLI on an interval so that the publisher is pushing a keyframe every rtcpPLIInterval
rtcpPLIInterval = 3 * time.Second
)
func New(desktop types.DesktopManager, capture types.CaptureManager, config *config.WebRTC) *WebRTCManagerCtx {
logger := log.With().Str("module", "webrtc").Logger()
configuration := webrtc.Configuration{
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
}
if !config.ICELite {
ICEServers := []webrtc.ICEServer{}
for _, server := range config.ICEServersBackend {
var credential any
if server.Credential != "" {
credential = server.Credential
} else {
credential = false
}
ICEServers = append(ICEServers, webrtc.ICEServer{
URLs: server.URLs,
Username: server.Username,
Credential: credential,
})
}
configuration.ICEServers = ICEServers
}
return &WebRTCManagerCtx{
logger: logger,
config: config,
metrics: newMetricsManager(),
webrtcConfiguration: configuration,
desktop: desktop,
capture: capture,
curImage: cursor.NewImage(logger, desktop),
curPosition: cursor.NewPosition(logger),
}
}
type WebRTCManagerCtx struct {
logger zerolog.Logger
config *config.WebRTC
metrics *metricsManager
peerId int32
desktop types.DesktopManager
capture types.CaptureManager
curImage cursor.Image
curPosition cursor.Position
webrtcConfiguration webrtc.Configuration
tcpMux ice.TCPMux
udpMux ice.UDPMux
camStop, micStop *func()
}
func (manager *WebRTCManagerCtx) Start() {
manager.curImage.Start()
logger := pionlog.New(manager.logger)
// add TCP Mux listener
if manager.config.TCPMux > 0 {
tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{
IP: net.IP{0, 0, 0, 0},
Port: manager.config.TCPMux,
})
if err != nil {
manager.logger.Fatal().Err(err).Msg("unable to setup ice TCP mux")
}
manager.tcpMux = ice.NewTCPMuxDefault(ice.TCPMuxParams{
Listener: tcpListener,
Logger: logger.NewLogger("ice-tcp"),
ReadBufferSize: tcpReadChanBufferSize,
WriteBufferSize: tcpWriteBufferSizeInBytes,
})
}
// add UDP Mux listener
if manager.config.UDPMux > 0 {
var err error
manager.udpMux, err = ice.NewMultiUDPMuxFromPort(manager.config.UDPMux,
ice.UDPMuxFromPortWithLogger(logger.NewLogger("ice-udp")),
)
if err != nil {
manager.logger.Fatal().Err(err).Msg("unable to setup ice UDP mux")
}
}
manager.logger.Info().
Bool("icelite", manager.config.ICELite).
Bool("icetrickle", manager.config.ICETrickle).
Interface("iceservers-frontend", manager.config.ICEServersFrontend).
Interface("iceservers-backend", manager.config.ICEServersBackend).
Str("nat1to1", strings.Join(manager.config.NAT1To1IPs, ",")).
Str("epr", fmt.Sprintf("%d-%d", manager.config.EphemeralMin, manager.config.EphemeralMax)).
Int("tcpmux", manager.config.TCPMux).
Int("udpmux", manager.config.UDPMux).
Msg("webrtc starting")
}
func (manager *WebRTCManagerCtx) Shutdown() error {
manager.logger.Info().Msg("shutdown")
manager.curImage.Shutdown()
manager.curPosition.Shutdown()
return nil
}
func (manager *WebRTCManagerCtx) ICEServers() []types.ICEServer {
return manager.config.ICEServersFrontend
}
func (manager *WebRTCManagerCtx) newPeerConnection(logger zerolog.Logger, codecs []codec.RTPCodec) (*webrtc.PeerConnection, cc.BandwidthEstimator, error) {
// create media engine
engine := &webrtc.MediaEngine{}
for _, codec := range codecs {
if err := codec.Register(engine); err != nil {
return nil, nil, err
}
}
// create setting engine
settings := webrtc.SettingEngine{
LoggerFactory: pionlog.New(logger),
}
settings.DisableMediaEngineCopy(true)
settings.SetICETimeouts(disconnectedTimeout, failedTimeout, keepAliveInterval)
settings.SetNAT1To1IPs(manager.config.NAT1To1IPs, webrtc.ICECandidateTypeHost)
settings.SetLite(manager.config.ICELite)
// make sure server answer sdp setup as passive, to not force DTLS renegotiation
// otherwise iOS renegotiation fails with: Failed to set SSL role for the transport.
settings.SetAnsweringDTLSRole(webrtc.DTLSRoleServer)
var networkType []webrtc.NetworkType
// udp candidates
if manager.udpMux != nil {
settings.SetICEUDPMux(manager.udpMux)
networkType = append(networkType,
webrtc.NetworkTypeUDP4,
webrtc.NetworkTypeUDP6,
)
} else if manager.config.EphemeralMax != 0 {
_ = settings.SetEphemeralUDPPortRange(manager.config.EphemeralMin, manager.config.EphemeralMax)
networkType = append(networkType,
webrtc.NetworkTypeUDP4,
webrtc.NetworkTypeUDP6,
)
}
// tcp candidates
if manager.tcpMux != nil {
settings.SetICETCPMux(manager.tcpMux)
networkType = append(networkType,
webrtc.NetworkTypeTCP4,
webrtc.NetworkTypeTCP6,
)
}
// enable support for TCP and UDP ICE candidates
settings.SetNetworkTypes(networkType)
// create interceptor registry
registry := &interceptor.Registry{}
// create bandwidth estimator
estimatorChan := make(chan cc.BandwidthEstimator, 1)
if manager.config.Estimator.Enabled {
congestionController, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) {
return gcc.NewSendSideBWE(
gcc.SendSideBWEInitialBitrate(manager.config.Estimator.InitialBitrate),
gcc.SendSideBWEPacer(gcc.NewNoOpPacer()),
)
})
if err != nil {
return nil, nil, err
}
congestionController.OnNewPeerConnection(func(id string, estimator cc.BandwidthEstimator) {
estimatorChan <- estimator
})
registry.Add(congestionController)
if err = webrtc.ConfigureTWCCHeaderExtensionSender(engine, registry); err != nil {
return nil, nil, err
}
} else {
// no estimator, send nil
estimatorChan <- nil
}
if err := webrtc.RegisterDefaultInterceptors(engine, registry); err != nil {
return nil, nil, err
}
// create new API
api := webrtc.NewAPI(
webrtc.WithMediaEngine(engine),
webrtc.WithSettingEngine(settings),
webrtc.WithInterceptorRegistry(registry),
)
// create new peer connection
configuration := manager.webrtcConfiguration
connection, err := api.NewPeerConnection(configuration)
return connection, <-estimatorChan, err
}
func (manager *WebRTCManagerCtx) CreatePeer(session types.Session) (*webrtc.SessionDescription, types.WebRTCPeer, error) {
id := atomic.AddInt32(&manager.peerId, 1)
// get metrics for session
metrics := manager.metrics.getBySession(session)
metrics.NewConnection()
// add session id to logger context
logger := manager.logger.With().Str("session_id", session.ID()).Int32("peer_id", id).Logger()
logger.Info().Msg("creating webrtc peer")
// all audios must have the same codec
audio := manager.capture.Audio()
audioCodec := audio.Codec()
// all videos must have the same codec
video := manager.capture.Video()
videoCodec := video.Codec()
connection, estimator, err := manager.newPeerConnection(
logger, []codec.RTPCodec{audioCodec, videoCodec})
if err != nil {
return nil, nil, err
}
// asynchronously send local ICE Candidates
if manager.config.ICETrickle {
connection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
logger.Debug().Msg("all local ice candidates sent")
return
}
session.Send(
event.SIGNAL_CANDIDATE,
message.SignalCandidate{
ICECandidateInit: candidate.ToJSON(),
})
})
}
// audio track
audioTrack, err := NewTrack(logger, audioCodec, connection)
if err != nil {
return nil, nil, err
}
// we disable audio by default manually
audioTrack.SetPaused(true)
// set stream for audio track
_, err = audioTrack.SetStream(audio)
if err != nil {
return nil, nil, err
}
// video track
videoRtcp := make(chan []rtcp.Packet, 1)
videoTrack, err := NewTrack(logger, videoCodec, connection, WithRtcpChan(videoRtcp))
if err != nil {
return nil, nil, err
}
//
// stream for video track will be set later
//
// data channel
dataChannel, err := connection.CreateDataChannel("data", nil)
if err != nil {
return nil, nil, err
}
peer := &WebRTCPeerCtx{
logger: logger,
session: session,
metrics: metrics,
connection: connection,
// bandwidth estimator
estimator: estimator,
estimateTrend: utils.NewTrendDetector(
utils.TrendDetectorParams{
// Probing
//RequiredSamples: 3,
//DownwardTrendThreshold: 0.0,
//CollapseValues: false,
// Non-Probing
RequiredSamples: 8,
DownwardTrendThreshold: -0.5,
CollapseValues: true,
}),
// stream selectors
video: video,
audio: audio,
// tracks & channels
audioTrack: audioTrack,
videoTrack: videoTrack,
dataChannel: dataChannel,
rtcpChannel: videoRtcp,
// config
iceTrickle: manager.config.ICETrickle,
estimatorConfig: manager.config.Estimator,
audioDisabled: true, // we disable audio by default manually
}
connection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
logger := logger.With().
Str("kind", track.Kind().String()).
Str("mime", track.Codec().RTPCodecCapability.MimeType).
Logger()
logger.Info().Msgf("received new remote track")
if !session.Profile().CanShareMedia {
err := receiver.Stop()
logger.Warn().Err(err).Msg("media sharing is disabled for this session")
return
}
// parse codec from remote track
codec, ok := codec.ParseRTC(track.Codec())
if !ok {
err := receiver.Stop()
logger.Warn().Err(err).Msg("remote track with unknown codec")
return
}
var srcManager types.StreamSrcManager
stopped := false
stopFn := func() {
if stopped {
return
}
stopped = true
err := receiver.Stop()
srcManager.Stop()
logger.Err(err).Msg("remote track stopped")
}
if track.Kind() == webrtc.RTPCodecTypeAudio {
// audio -> microphone
srcManager = manager.capture.Microphone()
defer stopFn()
if manager.micStop != nil {
(*manager.micStop)()
}
manager.micStop = &stopFn
} else if track.Kind() == webrtc.RTPCodecTypeVideo {
// video -> webcam
srcManager = manager.capture.Webcam()
defer stopFn()
if manager.camStop != nil {
(*manager.camStop)()
}
manager.camStop = &stopFn
} else {
err := receiver.Stop()
logger.Warn().Err(err).Msg("remote track with unsupported codec type")
return
}
err := srcManager.Start(codec)
if err != nil {
logger.Err(err).Msg("failed to start pipeline")
return
}
ticker := time.NewTicker(rtcpPLIInterval)
defer ticker.Stop()
go func() {
for range ticker.C {
err := connection.WriteRTCP([]rtcp.Packet{
&rtcp.PictureLossIndication{
MediaSSRC: uint32(track.SSRC()),
},
})
if err != nil {
logger.Err(err).Msg("remote track rtcp send err")
}
}
}()
buf := make([]byte, 1400)
for {
i, _, err := track.Read(buf)
if err != nil {
logger.Warn().Err(err).Msg("failed read from remote track")
break
}
srcManager.Push(buf[:i])
}
logger.Info().Msg("remote track data finished")
})
connection.OnDataChannel(func(dc *webrtc.DataChannel) {
logger.Info().Interface("data_channel", dc).Msg("got remote data channel")
})
var once sync.Once
connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
switch state {
case webrtc.PeerConnectionStateConnected:
session.SetWebRTCConnected(peer, true)
case webrtc.PeerConnectionStateDisconnected,
webrtc.PeerConnectionStateFailed:
peer.Destroy()
case webrtc.PeerConnectionStateClosed:
// ensure we only run this once
once.Do(func() {
session.SetWebRTCConnected(peer, false)
//
// TODO: Shutdown peer?
//
audioTrack.Shutdown()
videoTrack.Shutdown()
close(videoRtcp)
})
}
metrics.SetState(state)
})
dataChannel.OnOpen(func() {
manager.curImage.AddListener(peer)
manager.curPosition.AddListener(peer)
// send initial cursor image
cur, img, err := manager.curImage.GetCurrent()
if err == nil {
err := peer.SendCursorImage(cur, img)
if err != nil {
logger.Err(err).Msg("failed to set cursor image")
}
} else {
logger.Err(err).Msg("failed to get cursor image")
}
// send initial cursor position
x, y := manager.desktop.GetCursorPosition()
err = peer.SendCursorPosition(x, y)
if err != nil {
logger.Err(err).Msg("failed to set cursor position")
}
})
dataChannel.OnClose(func() {
manager.curImage.RemoveListener(peer)
manager.curPosition.RemoveListener(peer)
})
dataChannel.OnMessage(func(message webrtc.DataChannelMessage) {
if err := manager.handle(logger, message.Data, dataChannel, session); err != nil {
logger.Err(err).Msg("data handle failed")
}
})
session.SetWebRTCPeer(peer)
offer, err := peer.CreateOffer(false)
if err != nil {
return nil, nil, err
}
// on negotiation needed handler must be registered after creating initial
// offer, otherwise it can fire and intercept sucessful negotiation
connection.OnNegotiationNeeded(func() {
logger.Warn().Msg("negotiation is needed")
if connection.SignalingState() != webrtc.SignalingStateStable {
logger.Warn().Msg("connection isn't stable yet; postponing...")
return
}
offer, err := peer.CreateOffer(false)
if err != nil {
logger.Err(err).Msg("sdp offer failed")
return
}
session.Send(
event.SIGNAL_OFFER,
message.SignalDescription{
SDP: offer.SDP,
})
})
// start metrics collectors
go metrics.rtcpReceiver(videoRtcp)
go metrics.connectionStats(connection)
// start estimator reader
go peer.estimatorReader()
return offer, peer, nil
}
func (manager *WebRTCManagerCtx) SetCursorPosition(x, y int) {
manager.curPosition.Set(x, y)
}

458
internal/webrtc/metrics.go Normal file
View file

@ -0,0 +1,458 @@
package webrtc
import (
"sync"
"time"
"github.com/demodesk/neko/pkg/types"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v3"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
// how often to read and process webrtc connection stats
connectionStatsInterval = 5 * time.Second
)
type metricsManager struct {
mu sync.Mutex
sessions map[string]*metrics
}
func newMetricsManager() *metricsManager {
return &metricsManager{
sessions: map[string]*metrics{},
}
}
func (m *metricsManager) getBySession(session types.Session) *metrics {
m.mu.Lock()
defer m.mu.Unlock()
sessionId := session.ID()
met, ok := m.sessions[sessionId]
if ok {
return met
}
met = &metrics{
sessionId: sessionId,
connectionState: promauto.NewGauge(prometheus.GaugeOpts{
Name: "connection_state",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Connection state of session.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
connectionStateCount: promauto.NewCounter(prometheus.CounterOpts{
Name: "connection_state_count",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Count of connection state changes for a session.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
connectionCount: promauto.NewCounter(prometheus.CounterOpts{
Name: "connection_count",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Connection count of a session.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
iceCandidates: map[string]struct{}{},
iceCandidatesMu: &sync.Mutex{},
iceCandidatesUdpCount: promauto.NewCounter(prometheus.CounterOpts{
Name: "ice_candidates_count",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Count of ICE candidates sent by a remote client.",
ConstLabels: map[string]string{
"session_id": sessionId,
"protocol": "udp",
},
}),
iceCandidatesTcpCount: promauto.NewCounter(prometheus.CounterOpts{
Name: "ice_candidates_count",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Count of ICE candidates sent by a remote client.",
ConstLabels: map[string]string{
"session_id": sessionId,
"protocol": "tcp",
},
}),
iceCandidatesUsedUdp: promauto.NewGauge(prometheus.GaugeOpts{
Name: "ice_candidates_used",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Used ICE candidates that are currently in use.",
ConstLabels: map[string]string{
"session_id": sessionId,
"protocol": "udp",
},
}),
iceCandidatesUsedTcp: promauto.NewGauge(prometheus.GaugeOpts{
Name: "ice_candidates_used",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Used ICE candidates that are currently in use.",
ConstLabels: map[string]string{
"session_id": sessionId,
"protocol": "tcp",
},
}),
videoIds: map[string]prometheus.Gauge{},
videoIdsMu: &sync.Mutex{},
receiverEstimatedMaximumBitrate: promauto.NewGauge(prometheus.GaugeOpts{
Name: "receiver_estimated_maximum_bitrate",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Receiver Estimated Maximum Bitrate from RTCP.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
receiverEstimatedTargetBitrate: promauto.NewGauge(prometheus.GaugeOpts{
Name: "receiver_estimated_target_bitrate",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Receiver Estimated Target Bitrate using Google's congestion control.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
receiverReportDelay: promauto.NewGauge(prometheus.GaugeOpts{
Name: "receiver_report_delay",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Receiver Report Delay from RTCP, expressed in units of 1/65536 seconds.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
receiverReportJitter: promauto.NewGauge(prometheus.GaugeOpts{
Name: "receiver_report_jitter",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Receiver Report Jitter from RTCP.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
receiverReportTotalLost: promauto.NewGauge(prometheus.GaugeOpts{
Name: "receiver_report_total_lost",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Receiver Report Total Lost from RTCP.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
transportLayerNacks: promauto.NewCounter(prometheus.CounterOpts{
Name: "transport_layer_nacks",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Transport Layer NACKs from RTCP.",
ConstLabels: map[string]string{
"session_id": sessionId,
},
}),
iceBytesSent: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bytes_sent",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Sent bytes to a session.",
ConstLabels: map[string]string{
"session_id": sessionId,
"transport": "ice",
},
}),
iceBytesReceived: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bytes_received",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Received bytes from a session.",
ConstLabels: map[string]string{
"session_id": sessionId,
"transport": "ice",
},
}),
sctpBytesSent: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bytes_sent",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Sent bytes to a session.",
ConstLabels: map[string]string{
"session_id": sessionId,
"transport": "sctp",
},
}),
sctpBytesReceived: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bytes_received",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Received bytes from a session.",
ConstLabels: map[string]string{
"session_id": sessionId,
"transport": "sctp",
},
}),
}
m.sessions[sessionId] = met
return met
}
type metrics struct {
sessionId string
connectionState prometheus.Gauge
connectionStateCount prometheus.Counter
connectionCount prometheus.Counter
iceCandidates map[string]struct{}
iceCandidatesMu *sync.Mutex
iceCandidatesUdpCount prometheus.Counter
iceCandidatesTcpCount prometheus.Counter
iceCandidatesUsedUdp prometheus.Gauge
iceCandidatesUsedTcp prometheus.Gauge
videoIds map[string]prometheus.Gauge
videoIdsMu *sync.Mutex
receiverEstimatedMaximumBitrate prometheus.Gauge
receiverEstimatedTargetBitrate prometheus.Gauge
receiverReportDelay prometheus.Gauge
receiverReportJitter prometheus.Gauge
receiverReportTotalLost prometheus.Gauge
transportLayerNacks prometheus.Counter
iceBytesSent prometheus.Gauge
iceBytesReceived prometheus.Gauge
sctpBytesSent prometheus.Gauge
sctpBytesReceived prometheus.Gauge
}
func (met *metrics) reset() {
met.videoIdsMu.Lock()
for _, entry := range met.videoIds {
entry.Set(0)
}
met.videoIdsMu.Unlock()
met.iceCandidatesUsedUdp.Set(float64(0))
met.iceCandidatesUsedTcp.Set(float64(0))
met.receiverEstimatedMaximumBitrate.Set(0)
met.receiverReportDelay.Set(0)
met.receiverReportJitter.Set(0)
}
func (met *metrics) NewConnection() {
met.connectionCount.Add(1)
}
func (met *metrics) NewICECandidate(candidate webrtc.ICECandidateStats) {
met.iceCandidatesMu.Lock()
defer met.iceCandidatesMu.Unlock()
if _, found := met.iceCandidates[candidate.ID]; found {
return
}
met.iceCandidates[candidate.ID] = struct{}{}
if candidate.Protocol == "udp" {
met.iceCandidatesUdpCount.Add(1)
} else if candidate.Protocol == "tcp" {
met.iceCandidatesTcpCount.Add(1)
}
}
func (met *metrics) SetICECandidatesUsed(candidates []webrtc.ICECandidateStats) {
udp, tcp := 0, 0
for _, candidate := range candidates {
if candidate.Protocol == "udp" {
udp++
} else if candidate.Protocol == "tcp" {
tcp++
}
}
met.iceCandidatesUsedUdp.Set(float64(udp))
met.iceCandidatesUsedTcp.Set(float64(tcp))
}
func (met *metrics) SetState(state webrtc.PeerConnectionState) {
switch state {
case webrtc.PeerConnectionStateNew:
met.connectionState.Set(0)
case webrtc.PeerConnectionStateConnecting:
met.connectionState.Set(4)
case webrtc.PeerConnectionStateConnected:
met.connectionState.Set(5)
case webrtc.PeerConnectionStateDisconnected:
met.connectionState.Set(3)
case webrtc.PeerConnectionStateFailed:
met.connectionState.Set(2)
case webrtc.PeerConnectionStateClosed:
met.connectionState.Set(1)
met.reset()
default:
met.connectionState.Set(-1)
}
met.connectionStateCount.Add(1)
}
func (met *metrics) SetVideoID(videoId string) {
met.videoIdsMu.Lock()
defer met.videoIdsMu.Unlock()
if _, found := met.videoIds[videoId]; !found {
met.videoIds[videoId] = promauto.NewGauge(prometheus.GaugeOpts{
Name: "video_listeners",
Namespace: "neko",
Subsystem: "webrtc",
Help: "Listeners for Video pipelines by a session.",
ConstLabels: map[string]string{
"session_id": met.sessionId,
"video_id": videoId,
},
})
}
for id, entry := range met.videoIds {
if id == videoId {
entry.Set(1)
} else {
entry.Set(0)
}
}
}
func (met *metrics) SetReceiverEstimatedMaximumBitrate(bitrate float32) {
met.receiverEstimatedMaximumBitrate.Set(float64(bitrate))
}
func (met *metrics) SetReceiverEstimatedTargetBitrate(bitrate float64) {
met.receiverEstimatedTargetBitrate.Set(bitrate)
}
func (met *metrics) SetReceiverReport(report rtcp.ReceptionReport) {
met.receiverReportDelay.Set(float64(report.Delay))
met.receiverReportJitter.Set(float64(report.Jitter))
met.receiverReportTotalLost.Set(float64(report.TotalLost))
}
func (met *metrics) SetIceTransportStats(data webrtc.TransportStats) {
met.iceBytesSent.Set(float64(data.BytesSent))
met.iceBytesReceived.Set(float64(data.BytesReceived))
}
func (met *metrics) SetSctpTransportStats(data webrtc.TransportStats) {
met.sctpBytesSent.Set(float64(data.BytesSent))
met.sctpBytesReceived.Set(float64(data.BytesReceived))
}
//
// collectors
//
func (met *metrics) rtcpReceiver(rtcpCh chan []rtcp.Packet) {
for {
packets, ok := <-rtcpCh
if !ok {
break
}
for _, p := range packets {
switch rtcpPacket := p.(type) {
case *rtcp.ReceiverEstimatedMaximumBitrate: // TODO: Deprecated.
met.SetReceiverEstimatedMaximumBitrate(rtcpPacket.Bitrate)
case *rtcp.ReceiverReport:
l := len(rtcpPacket.Reports)
if l > 0 {
// use only last report
met.SetReceiverReport(rtcpPacket.Reports[l-1])
}
case *rtcp.TransportLayerNack:
for _, pair := range rtcpPacket.Nacks {
packetList := pair.PacketList()
met.transportLayerNacks.Add(float64(len(packetList)))
}
}
}
}
}
func (met *metrics) connectionStats(connection *webrtc.PeerConnection) {
ticker := time.NewTicker(connectionStatsInterval)
defer ticker.Stop()
for range ticker.C {
if connection.ConnectionState() == webrtc.PeerConnectionStateClosed {
break
}
stats := connection.GetStats()
data, ok := stats["iceTransport"].(webrtc.TransportStats)
if ok {
met.SetIceTransportStats(data)
}
data, ok = stats["sctpTransport"].(webrtc.TransportStats)
if ok {
met.SetSctpTransportStats(data)
}
remoteCandidates := map[string]webrtc.ICECandidateStats{}
nominatedRemoteCandidates := map[string]struct{}{}
for _, entry := range stats {
// only remote ice candidate stats
candidate, ok := entry.(webrtc.ICECandidateStats)
if ok && candidate.Type == webrtc.StatsTypeRemoteCandidate {
met.NewICECandidate(candidate)
remoteCandidates[candidate.ID] = candidate
}
// only nominated ice candidate pair stats
pair, ok := entry.(webrtc.ICECandidatePairStats)
if ok && pair.Nominated {
nominatedRemoteCandidates[pair.RemoteCandidateID] = struct{}{}
}
}
iceCandidatesUsed := []webrtc.ICECandidateStats{}
for id := range nominatedRemoteCandidates {
if candidate, ok := remoteCandidates[id]; ok {
iceCandidatesUsed = append(iceCandidatesUsed, candidate)
}
}
met.SetICECandidatesUsed(iceCandidatesUsed)
}
}

View file

@ -0,0 +1,55 @@
package payload
import "math"
const (
OP_MOVE = 0x01
OP_SCROLL = 0x02
OP_KEY_DOWN = 0x03
OP_KEY_UP = 0x04
OP_BTN_DOWN = 0x05
OP_BTN_UP = 0x06
OP_PING = 0x07
// touch events
OP_TOUCH_BEGIN = 0x08
OP_TOUCH_UPDATE = 0x09
OP_TOUCH_END = 0x0a
)
type Move struct {
X uint16
Y uint16
}
// TODO: remove this once the client is fixed
type Scroll_Old struct {
X int16
Y int16
}
type Scroll struct {
DeltaX int16
DeltaY int16
ControlKey bool
}
type Key struct {
Key uint32
}
type Ping struct {
// client's timestamp split into two uint32
ClientTs1 uint32
ClientTs2 uint32
}
func (p Ping) ClientTs() uint64 {
return (uint64(p.ClientTs1) * uint64(math.MaxUint32)) + uint64(p.ClientTs2)
}
type Touch struct {
TouchId uint32
X int32
Y int32
Pressure uint8
}

View file

@ -0,0 +1,33 @@
package payload
import "math"
const (
OP_CURSOR_POSITION = 0x01
OP_CURSOR_IMAGE = 0x02
OP_PONG = 0x03
)
type CursorPosition struct {
X uint16
Y uint16
}
type CursorImage struct {
Width uint16
Height uint16
Xhot uint16
Yhot uint16
}
type Pong struct {
Ping
// server's timestamp split into two uint32
ServerTs1 uint32
ServerTs2 uint32
}
func (p Pong) ServerTs() uint64 {
return (uint64(p.ServerTs1) * uint64(math.MaxUint32)) + uint64(p.ServerTs2)
}

View file

@ -0,0 +1,6 @@
package payload
type Header struct {
Event uint8
Length uint16
}

543
internal/webrtc/peer.go Normal file
View file

@ -0,0 +1,543 @@
package webrtc
import (
"bytes"
"encoding/binary"
"sync"
"time"
"github.com/pion/interceptor/pkg/cc"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v3"
"github.com/rs/zerolog"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/internal/webrtc/payload"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/types/event"
"github.com/demodesk/neko/pkg/utils"
)
type WebRTCPeerCtx struct {
mu sync.Mutex
logger zerolog.Logger
session types.Session
metrics *metrics
connection *webrtc.PeerConnection
// bandwidth estimator
estimator cc.BandwidthEstimator
estimateTrend *utils.TrendDetector
// stream selectors
video types.StreamSelectorManager
audio types.StreamSinkManager
// tracks & channels
audioTrack *Track
videoTrack *Track
dataChannel *webrtc.DataChannel
rtcpChannel chan []rtcp.Packet
// config
iceTrickle bool
estimatorConfig config.WebRTCEstimator
paused bool
videoAuto bool
videoDisabled bool
audioDisabled bool
}
//
// connection
//
func (peer *WebRTCPeerCtx) CreateOffer(ICERestart bool) (*webrtc.SessionDescription, error) {
peer.mu.Lock()
defer peer.mu.Unlock()
offer, err := peer.connection.CreateOffer(&webrtc.OfferOptions{
ICERestart: ICERestart,
})
if err != nil {
return nil, err
}
return peer.setLocalDescription(offer)
}
func (peer *WebRTCPeerCtx) CreateAnswer() (*webrtc.SessionDescription, error) {
peer.mu.Lock()
defer peer.mu.Unlock()
answer, err := peer.connection.CreateAnswer(nil)
if err != nil {
return nil, err
}
return peer.setLocalDescription(answer)
}
func (peer *WebRTCPeerCtx) setLocalDescription(description webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
if !peer.iceTrickle {
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peer.connection)
if err := peer.connection.SetLocalDescription(description); err != nil {
return nil, err
}
<-gatherComplete
} else {
if err := peer.connection.SetLocalDescription(description); err != nil {
return nil, err
}
}
return peer.connection.LocalDescription(), nil
}
func (peer *WebRTCPeerCtx) SetRemoteDescription(desc webrtc.SessionDescription) error {
peer.mu.Lock()
defer peer.mu.Unlock()
return peer.connection.SetRemoteDescription(desc)
}
func (peer *WebRTCPeerCtx) SetCandidate(candidate webrtc.ICECandidateInit) error {
peer.mu.Lock()
defer peer.mu.Unlock()
return peer.connection.AddICECandidate(candidate)
}
// TODO: Add shutdown function?
func (peer *WebRTCPeerCtx) Destroy() {
peer.mu.Lock()
defer peer.mu.Unlock()
var err error
// if peer connection is not closed, close it
if peer.connection.ConnectionState() != webrtc.PeerConnectionStateClosed {
err = peer.connection.Close()
}
peer.logger.Err(err).Msg("peer connection destroyed")
}
func (peer *WebRTCPeerCtx) estimatorReader() {
conf := peer.estimatorConfig
// if estimator is not in debug mode, use a nop logger
var debugLogger zerolog.Logger
if conf.Debug {
debugLogger = peer.logger.With().Str("component", "estimator").Logger().Level(zerolog.DebugLevel)
} else {
debugLogger = zerolog.Nop()
}
// if estimator is disabled, do nothing
if peer.estimator == nil {
return
}
// use a ticker to get current client target bitrate
ticker := time.NewTicker(conf.ReadInterval)
defer ticker.Stop()
// since when is the estimate stable/unstable
stableSince := time.Now() // we asume stable at start
unstableSince := time.Time{}
// since when are we neutral but cannot accomodate current bitrate
// we migt be stalled or estimator just reached zer (very bad connection)
stalledSince := time.Time{}
// when was the last upgrade/downgrade
lastUpgradeTime := time.Time{}
lastDowngradeTime := time.Time{}
for range ticker.C {
targetBitrate := peer.estimator.GetTargetBitrate()
peer.metrics.SetReceiverEstimatedTargetBitrate(float64(targetBitrate))
// if peer connection is closed, stop reading
if peer.connection.ConnectionState() == webrtc.PeerConnectionStateClosed {
break
}
// if estimation or video is disabled, do nothing
if !peer.videoAuto || peer.videoDisabled || peer.paused || conf.Passive {
continue
}
// get trend direction to decide if we should upgrade or downgrade
peer.estimateTrend.AddValue(int64(targetBitrate))
direction := peer.estimateTrend.GetDirection()
// get current stream bitrate
stream, ok := peer.videoTrack.Stream()
if !ok {
debugLogger.Warn().Msg("looks like we don't have a stream yet, skipping bitrate estimation")
continue
}
// if stream bitrate is 0, we need to wait for some time until we get a valid value
streamId, streamBitrate := stream.ID(), stream.Bitrate()
if streamBitrate == 0 {
debugLogger.Warn().Msg("looks like stream bitrate is 0, we need to wait for some time")
continue
}
// check whats the difference between target and stream bitrate
diff := float64(targetBitrate) / float64(streamBitrate)
debugLogger.Info().
Float64("diff", diff).
Int("target_bitrate", targetBitrate).
Uint64("stream_bitrate", streamBitrate).
Str("direction", direction.String()).
Msg("got bitrate from estimator")
// if we can accomodate current stream or we are not netural anymore,
// we are not stalled so we reset the stalled time
if direction != utils.TrendDirectionNeutral || diff > 1+conf.DiffThreshold {
stalledSince = time.Now()
}
// if we are neutral and stalled for too long, we might be congesting
stalled := direction == utils.TrendDirectionNeutral && time.Since(stalledSince) > conf.StalledDuration
if stalled {
debugLogger.Warn().
Time("stalled_since", stalledSince).
Msgf("it looks like we are stalled")
}
// if we have an downward trend or are stalled, we might be congesting
if direction == utils.TrendDirectionDownward || stalled {
// we reset the stable time because we are congesting
stableSince = time.Now()
// if we downgraded recently, we wait for some more time
if time.Since(lastDowngradeTime) < conf.DowngradeBackoff {
debugLogger.Debug().
Time("last_downgrade", lastDowngradeTime).
Msgf("downgraded recently, waiting for at least %v", conf.DowngradeBackoff)
continue
}
// if we are not unstable but we fluctuate we should wait for some more time
if time.Since(unstableSince) < conf.UnstableDuration {
debugLogger.Debug().
Time("unstable_since", unstableSince).
Msgf("we are not unstable long enough, waiting for at least %v", conf.UnstableDuration)
continue
}
// if we still have a big difference between target and stream bitrate, we wait for some more time
if conf.DiffThreshold >= 0 && diff > 1+conf.DiffThreshold {
debugLogger.Debug().
Float64("diff", diff).
Float64("threshold", conf.DiffThreshold).
Msgf("we still have a big difference between target and stream bitrate, " +
"therefore we still should be able to accomodate current stream")
continue
}
err := peer.SetVideo(types.PeerVideoRequest{
Selector: &types.StreamSelector{
ID: streamId,
Type: types.StreamSelectorTypeLower,
},
})
if err != nil && err != types.ErrWebRTCStreamNotFound {
peer.logger.Warn().Err(err).Msg("failed to downgrade video stream")
}
lastDowngradeTime = time.Now()
if err == types.ErrWebRTCStreamNotFound {
debugLogger.Info().Msg("looks like we are already on the lowest stream")
} else {
debugLogger.Info().Msg("downgraded video stream")
}
continue
}
// we reset the unstable time because we are not congesting
unstableSince = time.Now()
// if we have a neutral or upward trend, that means our estimate is stable
// if we are on the highest stream, we don't need to do anything
// but if there is a higher stream, we should try to upgrade and see if it works
// if we upgraded recently, we wait for some more time
if time.Since(lastUpgradeTime) < conf.UpgradeBackoff {
debugLogger.Debug().
Time("last_upgrade", lastUpgradeTime).
Msgf("upgraded recently, waiting for at least %v", conf.UpgradeBackoff)
continue
}
// if we are not stable for long enough, we wait for some more time
// because bandwidth estimation might fluctuate
if time.Since(stableSince) < conf.StableDuration {
debugLogger.Debug().
Time("stable_since", stableSince).
Msgf("we are not stable long enough, waiting for at least %v", conf.StableDuration)
continue
}
// upgrade only if estimated bitrate passed the threshold
if conf.DiffThreshold >= 0 && diff < 1+conf.DiffThreshold {
debugLogger.Debug().
Float64("diff", diff).
Float64("threshold", conf.DiffThreshold).
Msgf("looks like we don't have enough bitrate to accomodate higher stream, " +
"therefore we should wait for some more time")
continue
}
err := peer.SetVideo(types.PeerVideoRequest{
Selector: &types.StreamSelector{
ID: streamId,
Type: types.StreamSelectorTypeHigher,
},
})
if err != nil && err != types.ErrWebRTCStreamNotFound {
peer.logger.Warn().Err(err).Msg("failed to upgrade video stream")
}
lastUpgradeTime = time.Now()
if err == types.ErrWebRTCStreamNotFound {
debugLogger.Info().Msg("looks like we are already on the highest stream")
} else {
debugLogger.Info().Msg("upgraded video stream")
}
}
}
func (peer *WebRTCPeerCtx) SetPaused(isPaused bool) error {
peer.mu.Lock()
defer peer.mu.Unlock()
peer.videoTrack.SetPaused(isPaused || peer.videoDisabled)
peer.audioTrack.SetPaused(isPaused || peer.audioDisabled)
peer.logger.Info().Bool("is_paused", isPaused).Msg("set paused")
peer.paused = isPaused
return nil
}
func (peer *WebRTCPeerCtx) Paused() bool {
peer.mu.Lock()
defer peer.mu.Unlock()
return peer.paused
}
//
// video
//
func (peer *WebRTCPeerCtx) SetVideo(r types.PeerVideoRequest) error {
peer.mu.Lock()
defer peer.mu.Unlock()
modified := false
// video disabled
if r.Disabled != nil {
disabled := *r.Disabled
// update only if changed
if peer.videoDisabled != disabled {
peer.videoDisabled = disabled
peer.videoTrack.SetPaused(disabled || peer.paused)
peer.logger.Info().Bool("disabled", disabled).Msg("set video disabled")
modified = true
}
}
// video selector
if r.Selector != nil {
selector := *r.Selector
// get requested video stream from selector
stream, ok := peer.video.GetStream(selector)
if !ok {
return types.ErrWebRTCStreamNotFound
}
// set video stream to track
changed, err := peer.videoTrack.SetStream(stream)
if err != nil {
return err
}
// update only if stream changed
if changed {
videoID := stream.ID()
peer.metrics.SetVideoID(videoID)
peer.logger.Info().Str("video_id", videoID).Msg("set video")
modified = true
}
}
// video auto
if r.Auto != nil {
videoAuto := *r.Auto
if peer.estimator == nil || peer.estimatorConfig.Passive {
peer.logger.Warn().Msg("estimator is disabled or in passive mode, cannot change video auto")
videoAuto = false // ensure video auto is disabled
}
// update only if video auto changed
if peer.videoAuto != videoAuto {
peer.videoAuto = videoAuto
peer.logger.Info().Bool("video_auto", videoAuto).Msg("set video auto")
modified = true
}
}
// send video signal if modified
if modified {
go func() {
// in goroutine because of mutex and we don't want to block
peer.session.Send(event.SIGNAL_VIDEO, peer.Video())
}()
}
return nil
}
func (peer *WebRTCPeerCtx) Video() types.PeerVideo {
peer.mu.Lock()
defer peer.mu.Unlock()
// get current video stream ID
ID := ""
stream, ok := peer.videoTrack.Stream()
if ok {
ID = stream.ID()
}
return types.PeerVideo{
Disabled: peer.videoDisabled,
ID: ID,
Video: ID, // TODO: Remove, used for backward compatibility
Auto: peer.videoAuto,
}
}
//
// audio
//
func (peer *WebRTCPeerCtx) SetAudio(r types.PeerAudioRequest) error {
peer.mu.Lock()
defer peer.mu.Unlock()
modified := false
// audio disabled
if r.Disabled != nil {
disabled := *r.Disabled
// update only if changed
if peer.audioDisabled != disabled {
peer.audioDisabled = disabled
peer.audioTrack.SetPaused(disabled || peer.paused)
peer.logger.Info().Bool("disabled", disabled).Msg("set audio disabled")
modified = true
}
}
// send video signal if modified
if modified {
go func() {
// in goroutine because of mutex and we don't want to block
peer.session.Send(event.SIGNAL_AUDIO, peer.Audio())
}()
}
return nil
}
func (peer *WebRTCPeerCtx) Audio() types.PeerAudio {
peer.mu.Lock()
defer peer.mu.Unlock()
return types.PeerAudio{
Disabled: peer.audioDisabled,
}
}
//
// data channel
//
func (peer *WebRTCPeerCtx) SendCursorPosition(x, y int) error {
peer.mu.Lock()
defer peer.mu.Unlock()
// do not send cursor position to host
if peer.session.IsHost() {
return nil
}
header := payload.Header{
Event: payload.OP_CURSOR_POSITION,
Length: 7,
}
data := payload.CursorPosition{
X: uint16(x),
Y: uint16(y),
}
buffer := &bytes.Buffer{}
if err := binary.Write(buffer, binary.BigEndian, header); err != nil {
return err
}
if err := binary.Write(buffer, binary.BigEndian, data); err != nil {
return err
}
return peer.dataChannel.Send(buffer.Bytes())
}
func (peer *WebRTCPeerCtx) SendCursorImage(cur *types.CursorImage, img []byte) error {
peer.mu.Lock()
defer peer.mu.Unlock()
header := payload.Header{
Event: payload.OP_CURSOR_IMAGE,
Length: uint16(11 + len(img)),
}
data := payload.CursorImage{
Width: cur.Width,
Height: cur.Height,
Xhot: cur.Xhot,
Yhot: cur.Yhot,
}
buffer := &bytes.Buffer{}
if err := binary.Write(buffer, binary.BigEndian, header); err != nil {
return err
}
if err := binary.Write(buffer, binary.BigEndian, data); err != nil {
return err
}
if err := binary.Write(buffer, binary.BigEndian, img); err != nil {
return err
}
return peer.dataChannel.Send(buffer.Bytes())
}

Some files were not shown because too many files have changed in this diff Show more