Merge branch 'demodesk-v3' into v3

This commit is contained in:
Miroslav Šedivý 2024-09-06 23:53:06 +02:00
commit 356a566bc6
248 changed files with 78237 additions and 4508 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": "../server/",
"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"
}

View file

@ -6,4 +6,4 @@ indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = 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/server_build.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Create and publish a Docker image
on: workflow_dispatch
# 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: ./server/
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -0,0 +1,53 @@
name: Create and publish a Docker image variant
on: workflow_dispatch
# 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: ./server/
file: ${{ matrix.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

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: ./server/

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

@ -0,0 +1,23 @@
{
// 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}/server/cmd/neko",
"output": "${workspaceFolder}/server/bin/debug/neko",
"cwd": "${workspaceFolder}/server/",
"args": ["serve", "-d", "-c", "dev/runtime/config.yml"],
"env": {
"DISPLAY": ":99.0",
"PION_LOG_TRACE": "all",
}
}
]
}

14
.vscode/settings.json vendored
View file

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

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) 2020-2024 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.

View file

@ -1,2 +0,0 @@
DISPLAY=:99.0
PION_LOG_TRACE=all

12
server/.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
bin/
.idea
.env.development
runtime/fonts/*
!runtime/fonts/.gitkeep
runtime/icon-theme/*
!runtime/icon-theme/.gitkeep
plugins/*
!plugins/.gitkeep

View file

@ -1,16 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "launch",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/neko",
"envFile": "${workspaceFolder}/.env.development",
"output": "${workspaceFolder}/bin/debug/neko",
"cwd": "${workspaceFolder}/",
"args": ["serve", "-d", "--bind", ":3000", "--static", "../client/dist", "--password", "neko", "--password_admin", "admin"]
}
]
}

View file

@ -1,22 +0,0 @@
{
"go.formatTool": "goformat",
"go.inferGopath": false,
"go.autocompleteUnimportedPackages": true,
"go.delveConfig": {
"useApiV1": false,
"dlvLoadConfig": {
"followPointers": true,
"maxVariableRecurse": 3,
"maxStringLen": 400,
"maxArrayValues": 400,
"maxStructFields": -1
}
},
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}
}

182
server/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
server/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
server/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"]

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

@ -4,7 +4,12 @@
# aborting if any command returns a non-zero value
set -e
BUILD_TIME=`date -u +'%Y-%m-%dT%H:%M:%SZ'`
#
# 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
@ -13,12 +18,10 @@ 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`
GIT_DIRTY=`git diff-index --quiet HEAD -- || echo "✗-"`
GIT_COMMIT="${GIT_DIRTY}${GIT_COMMIT}"
fi
#
# load dependencies
# load server dependencies
go get -v -t -d .
#
@ -27,9 +30,58 @@ go build \
-o bin/neko \
-ldflags "
-s -w
-X 'm1k1o/neko.buildDate=${BUILD_TIME}'
-X 'm1k1o/neko.buildDate=`date -u +'%Y-%m-%dT%H:%M:%SZ'`'
-X 'm1k1o/neko.gitCommit=${GIT_COMMIT}'
-X 'm1k1o/neko.gitBranch=${GIT_BRANCH}'
-X 'm1k1o/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

View file

@ -7,11 +7,11 @@ import (
"m1k1o/neko"
"m1k1o/neko/cmd"
"m1k1o/neko/internal/utils"
"m1k1o/neko/pkg/utils"
)
func main() {
fmt.Print(utils.Colorf(neko.Header, "server", neko.Service.Version))
fmt.Print(utils.Colorf(neko.Header, "server", neko.Version))
if err := cmd.Execute(); err != nil {
log.Panic().Err(err).Msg("failed to execute command")
}

50
server/cmd/plugins.go Normal file
View file

@ -0,0 +1,50 @@
package cmd
import (
"encoding/json"
"os"
"m1k1o/neko/internal/config"
"m1k1o/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")
}
}

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/rs/zerolog"
@ -15,9 +16,18 @@ import (
"github.com/spf13/viper"
"m1k1o/neko"
"m1k1o/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()
}
@ -25,58 +35,24 @@ var root = &cobra.Command{
Use: "neko",
Short: "neko streaming server",
Long: `neko streaming server`,
Version: neko.Service.Version.String(),
Version: neko.Version.String(),
}
func init() {
rootConfig := config.Root{}
cobra.OnInitialize(func() {
//////
// logs
//////
zerolog.TimeFieldFormat = ""
zerolog.SetGlobalLevel(zerolog.InfoLevel)
console := zerolog.ConsoleWriter{Out: os.Stdout}
if !viper.GetBool("logs") {
log.Logger = log.Output(console)
} else {
logs := filepath.Join(".", "logs")
if runtime.GOOS == "linux" {
logs = "/var/log/neko"
}
if _, err := os.Stat(logs); os.IsNotExist(err) {
_ = os.Mkdir(logs, os.ModePerm)
}
latest := filepath.Join(logs, "neko-latest.log")
_, err := os.Stat(latest)
if err == nil {
err = os.Rename(latest, filepath.Join(logs, "neko."+time.Now().Format("2006-01-02T15-04-05Z07-00")+".log"))
if err != nil {
log.Panic().Err(err).Msg("failed to rotate log file")
}
}
logf, err := os.OpenFile(latest, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
log.Panic().Err(err).Msg("failed to create log file")
}
logger := diode.NewWriter(logf, 1000, 10*time.Millisecond, func(missed int) {
fmt.Printf("logger dropped %d messages", missed)
})
log.Logger = log.Output(io.MultiWriter(console, logger))
}
//////
// configs
//////
config := viper.GetString("config")
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) // Use config file from the flag.
viper.SetConfigFile(config)
} else {
if runtime.GOOS == "linux" {
viper.AddConfigPath("/etc/neko/")
@ -87,41 +63,103 @@ func init() {
}
viper.SetEnvPrefix("NEKO")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv() // read in environment variables that match
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
log.Error().Err(err)
}
if config != "" {
log.Error().Err(err)
// 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")
}
}
debug := viper.GetBool("debug")
if debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
// 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"
}
file := viper.ConfigFileUsed()
logger := log.With().
Bool("debug", debug).
Str("logging", viper.GetString("logs")).
Str("config", file).
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 file == "" {
if config == "" {
logger.Warn().Msg("preflight complete without config file")
} else {
logger.Info().Msg("preflight complete")
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")
}
}
neko.Service.Root.Set()
})
if err := neko.Service.Root.Init(root); err != nil {
if err := rootConfig.Init(root); err != nil {
log.Panic().Err(err).Msg("unable to run root command")
}
root.SetVersionTemplate(neko.Service.Version.Details())
root.SetVersionTemplate(neko.Version.Details())
}

View file

@ -1,41 +1,240 @@
package cmd
import (
"os"
"os/signal"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"m1k1o/neko"
"m1k1o/neko/internal/api"
"m1k1o/neko/internal/capture"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/desktop"
"m1k1o/neko/internal/http"
"m1k1o/neko/internal/member"
"m1k1o/neko/internal/plugins"
"m1k1o/neko/internal/session"
"m1k1o/neko/internal/webrtc"
"m1k1o/neko/internal/websocket"
)
func init() {
service := serve{}
command := &cobra.Command{
Use: "serve",
Short: "serve neko streaming server",
Long: `serve neko streaming server`,
Run: neko.Service.ServeCommand,
Use: "serve",
Short: "serve neko streaming server",
Long: `serve neko streaming server`,
PreRun: service.PreRun,
Run: service.Run,
}
configs := []config.Config{
neko.Service.Server,
neko.Service.WebRTC,
neko.Service.Capture,
neko.Service.Desktop,
neko.Service.WebSocket,
}
cobra.OnInitialize(func() {
for _, cfg := range configs {
cfg.Set()
}
neko.Service.Preflight()
})
for _, cfg := range configs {
if err := cfg.Init(command); err != nil {
log.Panic().Err(err).Msg("unable to run serve command")
}
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
}
// V2 configuration
if err := c.configs.Desktop.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Capture.InitV2(cmd); err != nil {
return err
}
if err := c.configs.WebRTC.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Member.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Session.InitV2(cmd); err != nil {
return err
}
if err := c.configs.Server.InitV2(cmd); err != nil {
return err
}
return nil
}
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()
c.configs.Desktop.SetV2()
c.configs.Capture.SetV2()
c.configs.WebRTC.SetV2()
c.configs.Member.SetV2()
c.configs.Session.SetV2()
c.configs.Server.SetV2()
}
func (c *serve) Start(cmd *cobra.Command) {
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
server/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
server/dev/exec Executable file
View file

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

12
server/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
server/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
server/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
server/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
server/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

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,122 @@
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"
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.
implicit_hosting: false
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,144 @@
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.
implicit_hosting: false
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

64
server/dev/start Executable file
View file

@ -0,0 +1,64 @@
#!/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 | awk '{print $1}')
fi
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;

View file

@ -1,55 +1,68 @@
module m1k1o/neko
go 1.20
go 1.21
require (
github.com/fsnotify/fsnotify v1.6.0
github.com/go-chi/chi/v5 v5.0.10
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.0
github.com/pion/ice/v2 v2.3.0
github.com/pion/interceptor v0.1.12
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/rtp v1.7.13 // indirect
github.com/pion/srtp/v2 v2.0.12 // indirect
github.com/pion/webrtc/v3 v3.1.55
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.29.0
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.15.0
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
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/google/uuid v1.3.0 // indirect
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/kr/pretty v0.3.1 // 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.17 // 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.0.6 // 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.6 // indirect
github.com/pion/mdns v0.0.7 // 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/rtcp v1.2.10 // indirect
github.com/pion/sctp v1.8.6 // 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/stun v0.4.0 // indirect
github.com/pion/transport/v2 v2.0.2 // indirect
github.com/pion/turn/v2 v2.1.0 // indirect
github.com/pion/udp/v2 v2.0.1 // indirect
github.com/spf13/afero v1.9.4 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // 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/subosito/gotenv v1.4.2 // 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
)

View file

@ -1,155 +1,57 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
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.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
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-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
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.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/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/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
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.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
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/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
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=
@ -158,13 +60,12 @@ 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.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
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.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/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=
@ -176,425 +77,221 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042
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.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
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.4/go.mod h1:WGKfxqhrddne4Kg3p11FUMJrynkOY4lb25zHNO49wuw=
github.com/pion/dtls/v2 v2.2.6 h1:yXMxKr0Skd+Ub6A8UqXTRLSywskx93ooMRHsQUtd+Z4=
github.com/pion/dtls/v2 v2.2.6/go.mod h1:t8fWJCIquY5rlQZwA2yWxUS1+OCrAdXrhVKXB5oD/wY=
github.com/pion/ice/v2 v2.3.0 h1:G+ysriabk1p9wbySDpdsnlD+6ZspLlDLagRduRfzJPk=
github.com/pion/ice/v2 v2.3.0/go.mod h1:+xO/cXVnnVUr6D2ZJcCT5g9LngucUkkTvfnTMqUxKRM=
github.com/pion/interceptor v0.1.12 h1:CslaNriCFUItiXS5o+hh5lpL0t0ytQkFnUcbbCs2Zq8=
github.com/pion/interceptor v0.1.12/go.mod h1:bDtgAD9dRkBZpWHGKaoKb42FhDHTG2rX8Ii9LRALLVA=
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.7 h1:P0UB4Sr6xDWEox0kTVxF0LmQihtCbSAdW0H2nEgkA3U=
github.com/pion/mdns v0.0.7/go.mod h1:4iP2UbeFhLI/vWju/bw6ZfwjJzk0z8DNValjGxR/dD8=
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 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc=
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
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.6 h1:CUex11Vkt9YS++VhLf8b55O3VqKrWL6W3SDwX4jAqsI=
github.com/pion/sctp v1.8.6/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.12 h1:WrmiVCubGMOAObBU1vwWjG0H3VSyQHawKeer2PVA5rY=
github.com/pion/srtp/v2 v2.0.12/go.mod h1:C3Ep44hlOo2qEYaq4ddsmK5dL63eLehXFbHaZ9F5V9Y=
github.com/pion/stun v0.4.0 h1:vgRrbBE2htWHy7l3Zsxckk7rkjnjOsSM7PHZnBwo8rk=
github.com/pion/stun v0.4.0/go.mod h1:QPsh1/SbXASntw3zkkrIk3ZJVKz4saBY2G7S10P3wCw=
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.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc=
github.com/pion/transport/v2 v2.0.1/go.mod h1:93OYg91+mrGxKW+Jrgzmqr80kgXqD7J0yybOrdr7w0Y=
github.com/pion/transport/v2 v2.0.2 h1:St+8o+1PEzPT51O9bv+tH/KYYLMNR5Vwm5Z3Qkjsywg=
github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0=
github.com/pion/turn/v2 v2.1.0 h1:5wGHSgGhJhP/RpabkUb/T9PdsAjkGLS6toYz5HNzoSI=
github.com/pion/turn/v2 v2.1.0/go.mod h1:yrT5XbXSGX1VFSF31A3c1kCNB5bBZgk/uu5LET162qs=
github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us=
github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8=
github.com/pion/webrtc/v3 v3.1.55 h1:jQt98hZ8DUi/l/s/rtogthBdsKKvKekFgZCX9hMEqRo=
github.com/pion/webrtc/v3 v3.1.55/go.mod h1:M1gU5mnvvo4e1nnLvF23esYz0nZAFOtbU/wq44MSfbc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
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/spf13/afero v1.9.4 h1:Sd43wM1IWz/s1aVXdOBkjJvuP8UdyqioeE4AmM0QsBs=
github.com/spf13/afero v1.9.4/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
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.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/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-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/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-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/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-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/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.0.0-20220908164124-27713097b956/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.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
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.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/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.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
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/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
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.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
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=
@ -606,13 +303,3 @@ 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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View file

@ -0,0 +1,84 @@
package members
import (
"encoding/json"
"io"
"net/http"
"m1k1o/neko/pkg/types"
"m1k1o/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"
"m1k1o/neko/pkg/types"
"m1k1o/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"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/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"
"m1k1o/neko/pkg/types/event"
"m1k1o/neko/pkg/types/message"
"m1k1o/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) broadcastStart(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.BROADCAST_STATUS,
message.BroadcastStatus{
IsActive: broadcast.Started(),
URL: broadcast.Url(),
})
return utils.HttpSuccess(w)
}
func (h *RoomHandler) broadcastStop(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.BROADCAST_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"
"m1k1o/neko/pkg/types"
"m1k1o/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,109 @@
package room
import (
"net/http"
"github.com/go-chi/chi"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types/event"
"m1k1o/neko/pkg/types/message"
"m1k1o/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 {
session, _ := auth.GetSession(r)
host, hasHost := h.sessions.GetHost()
if hasHost {
// TODO: Some throttling mechanism to prevent spamming.
// let host know that someone wants to take control
host.Send(
event.CONTROL_REQUEST,
message.SessionID{
ID: session.ID(),
})
return utils.HttpError(http.StatusAccepted, "control request sent")
}
if h.sessions.Settings().LockedControls && !session.Profile().IsAdmin {
return utils.HttpForbidden("controls are locked")
}
session.SetAsHost()
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()
session.ClearHost()
return utils.HttpSuccess(w)
}
func (h *RoomHandler) controlTake(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
session.SetAsHost()
return utils.HttpSuccess(w)
}
func (h *RoomHandler) controlGive(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
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")
}
target.SetAsHostBy(session)
return utils.HttpSuccess(w)
}
func (h *RoomHandler) controlReset(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
_, hasHost := h.sessions.GetHost()
if hasHost {
h.desktop.ResetKeys()
session.ClearHost()
}
return utils.HttpSuccess(w)
}

View file

@ -0,0 +1,126 @@
package room
import (
"context"
"net/http"
"github.com/rs/zerolog/log"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/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(session types.Session, new, 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.broadcastStart)
r.Post("/stop", h.broadcastStop)
})
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.HostsOrAdminsOnly).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,47 @@
package room
import (
"net/http"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/utils"
)
func (h *RoomHandler) keyboardMapSet(w http.ResponseWriter, r *http.Request) error {
keyboardMap := types.KeyboardMap{}
if err := utils.HttpJsonRequest(w, r, &keyboardMap); err != nil {
return err
}
err := h.desktop.SetKeyboardMap(keyboardMap)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w)
}
func (h *RoomHandler) keyboardMapGet(w http.ResponseWriter, r *http.Request) error {
keyboardMap, err := h.desktop.GetKeyboardMap()
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
return utils.HttpSuccess(w, keyboardMap)
}
func (h *RoomHandler) keyboardModifiersSet(w http.ResponseWriter, r *http.Request) error {
keyboardModifiers := types.KeyboardModifiers{}
if err := utils.HttpJsonRequest(w, r, &keyboardModifiers); err != nil {
return err
}
h.desktop.SetKeyboardModifiers(keyboardModifiers)
return utils.HttpSuccess(w)
}
func (h *RoomHandler) keyboardModifiersGet(w http.ResponseWriter, r *http.Request) error {
keyboardModifiers := h.desktop.GetKeyboardModifiers()
return utils.HttpSuccess(w, keyboardModifiers)
}

View file

@ -0,0 +1,101 @@
package room
import (
"net/http"
"strconv"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/types/event"
"m1k1o/neko/pkg/types/message"
"m1k1o/neko/pkg/utils"
)
func (h *RoomHandler) screenConfiguration(w http.ResponseWriter, r *http.Request) error {
screenSize := h.desktop.GetScreenSize()
return utils.HttpSuccess(w, screenSize)
}
func (h *RoomHandler) screenConfigurationChange(w http.ResponseWriter, r *http.Request) error {
auth, _ := auth.GetSession(r)
data := &types.ScreenSize{}
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.ScreenSizeUpdate{
ID: auth.ID(),
ScreenSize: size,
})
return utils.HttpSuccess(w, data)
}
// TODO: remove.
func (h *RoomHandler) screenConfigurationsList(w http.ResponseWriter, r *http.Request) error {
configurations := h.desktop.ScreenConfigurations()
return utils.HttpSuccess(w, configurations)
}
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,38 @@
package room
import (
"encoding/json"
"io"
"net/http"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/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 {
session, _ := auth.GetSession(r)
// We read the request body first and unmashal it inside the UpdateSettingsFunc
// to ensure atomicity of the operation.
body, err := io.ReadAll(r.Body)
if err != nil {
return utils.HttpBadRequest("unable to read request body").WithInternalErr(err)
}
h.sessions.UpdateSettingsFunc(session, func(settings *types.Settings) bool {
err = json.Unmarshal(body, settings)
return err == nil
})
if err != nil {
return utils.HttpBadRequest("unable to parse provided data").WithInternalErr(err)
}
return utils.HttpSuccess(w)
}

View file

@ -0,0 +1,172 @@
package room
import (
"io"
"net/http"
"os"
"path"
"strconv"
"m1k1o/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)
}

View file

@ -0,0 +1,86 @@
package api
import (
"context"
"errors"
"net/http"
"m1k1o/neko/internal/api/members"
"m1k1o/neko/internal/api/room"
"m1k1o/neko/internal/api/sessions"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/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.Post("/profile", api.UpdateProfile)
sessionsHandler := sessions.New(api.sessions)
r.Route("/sessions", sessionsHandler.Route)
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
}

View file

@ -0,0 +1,105 @@
package api
import (
"errors"
"net/http"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/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 if errors.Is(err, types.ErrSessionLoginsLocked) {
return utils.HttpForbidden("logins are locked").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) UpdateProfile(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
data := session.Profile()
if err := utils.HttpJsonRequest(w, r, &data); err != nil {
return err
}
err := api.sessions.Update(session.ID(), data)
if err != nil {
if errors.Is(err, types.ErrSessionNotFound) {
return utils.HttpBadRequest("session does not exist")
} else {
return utils.HttpInternalServerError().WithInternalErr(err)
}
}
return utils.HttpSuccess(w, true)
}

View file

@ -0,0 +1,81 @@
package sessions
import (
"errors"
"net/http"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/utils"
"github.com/go-chi/chi"
)
type SessionDataPayload struct {
ID string `json:"id"`
Profile types.MemberProfile `json:"profile"`
State types.SessionState `json:"state"`
}
func (h *SessionsHandler) sessionsList(w http.ResponseWriter, r *http.Request) error {
sessions := []SessionDataPayload{}
for _, session := range h.sessions.List() {
sessions = append(sessions, SessionDataPayload{
ID: session.ID(),
Profile: session.Profile(),
State: session.State(),
})
}
return utils.HttpSuccess(w, sessions)
}
func (h *SessionsHandler) sessionsRead(w http.ResponseWriter, r *http.Request) error {
sessionId := chi.URLParam(r, "sessionId")
session, ok := h.sessions.Get(sessionId)
if !ok {
return utils.HttpNotFound("session not found")
}
return utils.HttpSuccess(w, SessionDataPayload{
ID: session.ID(),
Profile: session.Profile(),
State: session.State(),
})
}
func (h *SessionsHandler) sessionsDelete(w http.ResponseWriter, r *http.Request) error {
session, _ := auth.GetSession(r)
sessionId := chi.URLParam(r, "sessionId")
if sessionId == session.ID() {
return utils.HttpBadRequest("cannot delete own session")
}
err := h.sessions.Delete(sessionId)
if err != nil {
if errors.Is(err, types.ErrSessionNotFound) {
return utils.HttpBadRequest("session not found")
} else {
return utils.HttpInternalServerError().WithInternalErr(err)
}
}
return utils.HttpSuccess(w)
}
func (h *SessionsHandler) sessionsDisconnect(w http.ResponseWriter, r *http.Request) error {
sessionId := chi.URLParam(r, "sessionId")
err := h.sessions.Disconnect(sessionId)
if err != nil {
if errors.Is(err, types.ErrSessionNotFound) {
return utils.HttpBadRequest("session not found")
} else {
return utils.HttpInternalServerError().WithInternalErr(err)
}
}
return utils.HttpSuccess(w)
}

View file

@ -0,0 +1,30 @@
package sessions
import (
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
)
type SessionsHandler struct {
sessions types.SessionManager
}
func New(
sessions types.SessionManager,
) *SessionsHandler {
// Init
return &SessionsHandler{
sessions: sessions,
}
}
func (h *SessionsHandler) Route(r types.Router) {
r.Get("/", h.sessionsList)
r.With(auth.AdminsOnly).Route("/{sessionId}", func(r types.Router) {
r.Get("/", h.sessionsRead)
r.Delete("/", h.sessionsDelete)
r.Post("/disconnect", h.sessionsDisconnect)
})
}

View file

@ -3,26 +3,32 @@ 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"
"m1k1o/neko/internal/capture/gst"
"m1k1o/neko/internal/types"
"m1k1o/neko/pkg/gst"
"m1k1o/neko/pkg/types"
)
type BroacastManagerCtx struct {
logger zerolog.Logger
mu sync.Mutex
pipeline *gst.Pipeline
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), url string, started bool) *BroacastManagerCtx {
func broadcastNew(pipelineFn func(url string) (string, error), defaultUrl string, autostart bool) *BroacastManagerCtx {
logger := log.With().
Str("module", "capture").
Str("submodule", "broadcast").
@ -31,8 +37,34 @@ func broadcastNew(pipelineFn func(url string) (string, error), url string, start
return &BroacastManagerCtx{
logger: logger,
pipelineFn: pipelineFn,
url: url,
started: started && url != "",
url: defaultUrl,
started: defaultUrl != "" && autostart,
// 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": "-",
},
}),
}
}
@ -86,7 +118,6 @@ func (manager *BroacastManagerCtx) createPipeline() error {
return types.ErrCapturePipelineAlreadyExists
}
var err error
pipelineStr, err := manager.pipelineFn(manager.url)
if err != nil {
return err
@ -103,6 +134,8 @@ func (manager *BroacastManagerCtx) createPipeline() error {
}
manager.pipeline.Play()
manager.pipelinesCounter.Inc()
manager.pipelinesActive.Set(1)
return nil
}
@ -118,4 +151,6 @@ func (manager *BroacastManagerCtx) destroyPipeline() {
manager.pipeline.Destroy()
manager.logger.Info().Msgf("destroying pipeline")
manager.pipeline = nil
manager.pipelinesActive.Set(0)
}

View file

@ -2,48 +2,189 @@ package capture
import (
"errors"
"fmt"
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko/internal/capture/gst"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/types"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/types/codec"
)
type CaptureManagerCtx struct {
logger zerolog.Logger
desktop types.DesktopManager
config *config.Capture
// sinks
broadcast *BroacastManagerCtx
audio *StreamSinkManagerCtx
video *StreamSinkManagerCtx
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) {
return NewBroadcastPipeline(config.AudioDevice, config.Display, config.BroadcastPipeline, url)
}, config.BroadcastUrl, config.BroadcastAutostart),
audio: streamSinkNew(config.AudioCodec, func() (string, error) {
return NewAudioPipeline(config.AudioCodec, config.AudioDevice, config.AudioPipeline, config.AudioBitrate)
}, "audio"),
video: streamSinkNew(config.VideoCodec, func() (string, error) {
// use screen fps as default
fps := desktop.GetScreenSize().Rate
// if max fps is set, cap it to that value
if config.VideoMaxFPS > 0 && config.VideoMaxFPS < fps {
fps = config.VideoMaxFPS
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 NewVideoPipeline(config.VideoCodec, config.Display, config.VideoPipeline, fps, config.VideoBitrate, config.VideoHWEnc)
}, "video"),
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, config.BroadcastAutostart),
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"),
}
}
@ -54,55 +195,51 @@ func (manager *CaptureManagerCtx) Start() {
}
}
go gst.RunMainLoop()
go func() {
for {
before, ok := <-manager.desktop.GetScreenSizeChangeChannel()
if !ok {
manager.logger.Info().Msg("screen size change channel was closed")
return
}
manager.desktop.OnBeforeScreenSizeChange(func() {
manager.video.destroyPipelines()
if before {
// before screen size change, we need to destroy all pipelines
if manager.broadcast.Started() {
manager.broadcast.destroyPipeline()
}
if manager.video.Started() {
manager.video.destroyPipeline()
}
if manager.screencast.Started() {
manager.screencast.destroyPipeline()
}
})
if manager.broadcast.Started() {
manager.broadcast.destroyPipeline()
}
} else {
// after screen size change, we need to recreate all pipelines
manager.desktop.OnAfterScreenSizeChange(func() {
err := manager.video.recreatePipelines()
if err != nil {
manager.logger.Panic().Err(err).Msg("unable to recreate video pipelines")
}
if manager.video.Started() {
err := manager.video.createPipeline()
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
manager.logger.Panic().Err(err).Msg("unable to recreate video pipeline")
}
}
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.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()
gst.QuitMainLoop()
manager.webcam.shutdown()
manager.microphone.shutdown()
return nil
}
@ -111,10 +248,22 @@ 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.StreamSinkManager {
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"
"m1k1o/neko/pkg/gst"
"m1k1o/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"
"m1k1o/neko/pkg/types"
"m1k1o/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

@ -2,41 +2,122 @@ 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"
"m1k1o/neko/internal/capture/gst"
"m1k1o/neko/internal/types"
"m1k1o/neko/internal/types/codec"
"m1k1o/neko/pkg/gst"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/types/codec"
)
var moveSinkListenerMu = sync.Mutex{}
type StreamSinkManagerCtx struct {
logger zerolog.Logger
mu sync.Mutex
sampleChannel chan types.Sample
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
pipeline gst.Pipeline
pipelineMu sync.Mutex
pipelineFn func() (string, error)
listeners int
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), video_id string) *StreamSinkManagerCtx {
func streamSinkNew(codec codec.RTPCodec, pipelineFn func() (string, error), id string) *StreamSinkManagerCtx {
logger := log.With().
Str("module", "capture").
Str("submodule", "stream-sink").
Str("video_id", video_id).Logger()
Str("id", id).Logger()
manager := &StreamSinkManagerCtx{
logger: logger,
codec: codec,
pipelineFn: pipelineFn,
sampleChannel: make(chan types.Sample),
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
@ -45,7 +126,25 @@ func streamSinkNew(codec codec.RTPCodec, pipelineFn func() (string, error), vide
func (manager *StreamSinkManagerCtx) shutdown() {
manager.logger.Info().Msgf("shutdown")
manager.destroyPipeline()
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 {
@ -53,8 +152,8 @@ func (manager *StreamSinkManagerCtx) Codec() codec.RTPCodec {
}
func (manager *StreamSinkManagerCtx) start() error {
if manager.listeners == 0 {
err := manager.createPipeline()
if len(manager.listeners)+len(manager.listenersKf) == 0 {
err := manager.CreatePipeline()
if err != nil && !errors.Is(err, types.ErrCapturePipelineAlreadyExists) {
return err
}
@ -66,45 +165,123 @@ func (manager *StreamSinkManagerCtx) start() error {
}
func (manager *StreamSinkManagerCtx) stop() {
if manager.listeners == 0 {
manager.destroyPipeline()
if len(manager.listeners)+len(manager.listenersKf) == 0 {
manager.DestroyPipeline()
manager.logger.Info().Msgf("last listener, stopping")
}
}
func (manager *StreamSinkManagerCtx) addListener() {
func (manager *StreamSinkManagerCtx) addListener(listener types.SampleListener) {
ptr := reflect.ValueOf(listener).Pointer()
emitKeyframe := false
manager.listenersMu.Lock()
manager.listeners++
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() {
func (manager *StreamSinkManagerCtx) removeListener(listener types.SampleListener) {
ptr := reflect.ValueOf(listener).Pointer()
manager.listenersMu.Lock()
manager.listeners--
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() error {
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()
manager.addListener(listener)
return nil
}
func (manager *StreamSinkManagerCtx) RemoveListener() error {
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()
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()
@ -116,14 +293,14 @@ func (manager *StreamSinkManagerCtx) ListenersCount() int {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
return manager.listeners
return len(manager.listeners) + len(manager.listenersKf)
}
func (manager *StreamSinkManagerCtx) Started() bool {
return manager.ListenersCount() > 0
}
func (manager *StreamSinkManagerCtx) createPipeline() error {
func (manager *StreamSinkManagerCtx) CreatePipeline() error {
manager.pipelineMu.Lock()
defer manager.pipelineMu.Unlock()
@ -146,18 +323,79 @@ func (manager *StreamSinkManagerCtx) createPipeline() error {
return err
}
appsinkSubfix := "audio"
if manager.codec.IsVideo() {
appsinkSubfix = "video"
}
manager.pipeline.AttachAppsink("appsink"+appsinkSubfix, manager.sampleChannel)
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) destroyPipeline() {
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()
@ -168,8 +406,9 @@ func (manager *StreamSinkManagerCtx) destroyPipeline() {
manager.pipeline.Destroy()
manager.logger.Info().Msgf("destroying pipeline")
manager.pipeline = nil
}
func (manager *StreamSinkManagerCtx) GetSampleChannel() chan types.Sample {
return manager.sampleChannel
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"
"m1k1o/neko/pkg/gst"
"m1k1o/neko/pkg/types"
"m1k1o/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
}

View file

@ -1,55 +1,197 @@
package config
import (
"m1k1o/neko/internal/types/codec"
"os"
"strings"
"github.com/pion/webrtc/v3"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/types/codec"
"m1k1o/neko/pkg/utils"
)
// Legacy capture configuration
type HwEnc int
// Legacy capture configuration
const (
HwEncNone HwEnc = iota
HwEncUnset HwEnc = iota
HwEncNone
HwEncVAAPI
HwEncNVENC
)
type Capture struct {
// video
Display string
VideoCodec codec.RTPCodec
VideoHWEnc HwEnc // TODO: Pipeline builder.
VideoBitrate uint // TODO: Pipeline builder.
VideoMaxFPS int16 // TODO: Pipeline builder.
VideoPipeline string
Display string
VideoCodec codec.RTPCodec
VideoIDs []string
VideoPipelines map[string]types.VideoConfig
// audio
AudioDevice string
AudioCodec codec.RTPCodec
AudioBitrate uint // TODO: Pipeline builder.
AudioPipeline string
// broadcast
BroadcastPipeline string
BroadcastUrl string
BroadcastAutostart bool
BroadcastAudioBitrate int
BroadcastVideoBitrate int
BroadcastPreset string
BroadcastPipeline string
BroadcastUrl string
BroadcastAutostart bool
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 {
//
// video
//
// 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("display", ":99.0", "XDisplay to capture")
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.display", "", "X display to capture")
if err := viper.BindPFlag("capture.video.display", cmd.PersistentFlags().Lookup("capture.video.display")); err != nil {
return err
}
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
}
cmd.PersistentFlags().Bool("capture.broadcast.autostart", true, "automatically start broadcasting when neko starts and broadcast_url is set")
if err := viper.BindPFlag("capture.broadcast.autostart", cmd.PersistentFlags().Lookup("capture.broadcast.autostart")); 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 (Capture) InitV2(cmd *cobra.Command) error {
cmd.PersistentFlags().String("display", "", "V2: XDisplay to capture")
if err := viper.BindPFlag("display", cmd.PersistentFlags().Lookup("display")); err != nil {
return err
}
cmd.PersistentFlags().String("video_codec", "vp8", "video codec to be used")
cmd.PersistentFlags().String("video_codec", "", "V2: video codec to be used")
if err := viper.BindPFlag("video_codec", cmd.PersistentFlags().Lookup("video_codec")); err != nil {
return err
}
@ -78,22 +220,22 @@ func (Capture) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().String("hwenc", "", "use hardware accelerated encoding")
cmd.PersistentFlags().String("hwenc", "", "V2: use hardware accelerated encoding")
if err := viper.BindPFlag("hwenc", cmd.PersistentFlags().Lookup("hwenc")); err != nil {
return err
}
cmd.PersistentFlags().Int("video_bitrate", 3072, "video bitrate in kbit/s")
cmd.PersistentFlags().Int("video_bitrate", 0, "V2: video bitrate in kbit/s")
if err := viper.BindPFlag("video_bitrate", cmd.PersistentFlags().Lookup("video_bitrate")); err != nil {
return err
}
cmd.PersistentFlags().Int("max_fps", 25, "maximum fps delivered via WebRTC, 0 is for no maximum")
cmd.PersistentFlags().Int("max_fps", 0, "V2: maximum fps delivered via WebRTC, 0 is for no maximum")
if err := viper.BindPFlag("max_fps", cmd.PersistentFlags().Lookup("max_fps")); err != nil {
return err
}
cmd.PersistentFlags().String("video", "", "video codec parameters to use for streaming")
cmd.PersistentFlags().String("video", "", "V2: video codec parameters to use for streaming")
if err := viper.BindPFlag("video", cmd.PersistentFlags().Lookup("video")); err != nil {
return err
}
@ -102,12 +244,12 @@ func (Capture) Init(cmd *cobra.Command) error {
// audio
//
cmd.PersistentFlags().String("device", "audio_output.monitor", "audio device to capture")
cmd.PersistentFlags().String("device", "", "V2: audio device to capture")
if err := viper.BindPFlag("device", cmd.PersistentFlags().Lookup("device")); err != nil {
return err
}
cmd.PersistentFlags().String("audio_codec", "opus", "audio codec to be used")
cmd.PersistentFlags().String("audio_codec", "", "V2: audio codec to be used")
if err := viper.BindPFlag("audio_codec", cmd.PersistentFlags().Lookup("audio_codec")); err != nil {
return err
}
@ -137,12 +279,12 @@ func (Capture) Init(cmd *cobra.Command) error {
}
// audio codecs
cmd.PersistentFlags().Int("audio_bitrate", 128, "audio bitrate in kbit/s")
cmd.PersistentFlags().Int("audio_bitrate", 0, "V2: audio bitrate in kbit/s")
if err := viper.BindPFlag("audio_bitrate", cmd.PersistentFlags().Lookup("audio_bitrate")); err != nil {
return err
}
cmd.PersistentFlags().String("audio", "", "audio codec parameters to use for streaming")
cmd.PersistentFlags().String("audio", "", "V2: audio codec parameters to use for streaming")
if err := viper.BindPFlag("audio", cmd.PersistentFlags().Lookup("audio")); err != nil {
return err
}
@ -151,17 +293,17 @@ func (Capture) Init(cmd *cobra.Command) error {
// broadcast
//
cmd.PersistentFlags().String("broadcast_pipeline", "", "custom gst pipeline used for broadcasting, strings {url} {device} {display} will be replaced")
cmd.PersistentFlags().String("broadcast_pipeline", "", "V2: custom gst pipeline used for broadcasting, strings {url} {device} {display} will be replaced")
if err := viper.BindPFlag("broadcast_pipeline", cmd.PersistentFlags().Lookup("broadcast_pipeline")); err != nil {
return err
}
cmd.PersistentFlags().String("broadcast_url", "", "a default default URL for broadcast streams, can be disabled/changed later by admins in the GUI")
cmd.PersistentFlags().String("broadcast_url", "", "V2: a default default URL for broadcast streams, can be disabled/changed later by admins in the GUI")
if err := viper.BindPFlag("broadcast_url", cmd.PersistentFlags().Lookup("broadcast_url")); err != nil {
return err
}
cmd.PersistentFlags().Bool("broadcast_autostart", true, "automatically start broadcasting when neko starts and broadcast_url is set")
cmd.PersistentFlags().Bool("broadcast_autostart", false, "V2: automatically start broadcasting when neko starts and broadcast_url is set")
if err := viper.BindPFlag("broadcast_autostart", cmd.PersistentFlags().Lookup("broadcast_autostart")); err != nil {
return err
}
@ -172,86 +314,220 @@ func (Capture) Init(cmd *cobra.Command) error {
func (s *Capture) Set() {
var ok bool
//
s.Display = viper.GetString("capture.video.display")
// Display is provided by env variable unless explicitly set
if s.Display == "" {
s.Display = os.Getenv("DISPLAY")
}
// video
//
s.Display = viper.GetString("display")
videoCodec := viper.GetString("video_codec")
videoCodec := viper.GetString("capture.video.codec")
s.VideoCodec, ok = codec.ParseStr(videoCodec)
if !ok || s.VideoCodec.Type != webrtc.RTPCodecTypeVideo {
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")
s.BroadcastAutostart = viper.GetBool("capture.broadcast.autostart")
// 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")
}
func (s *Capture) SetV2() {
var ok bool
//
// video
//
if display := viper.GetString("display"); display != "" {
s.Display = display
log.Warn().Msg("you are using v2 configuration 'NEKO_DISPLAY' which is deprecated, please use 'NEKO_CAPTURE_VIDEO_DISPLAY' and/or 'NEKO_DESKTOP_DISPLAY' instead, also consider using 'DISPLAY' env variable if both should be the same")
}
if videoCodec := viper.GetString("video_codec"); videoCodec != "" {
s.VideoCodec, ok = codec.ParseStr(videoCodec)
if !ok || s.VideoCodec.Type != webrtc.RTPCodecTypeVideo {
log.Warn().Str("codec", videoCodec).Msgf("unknown video codec, using Vp8")
s.VideoCodec = codec.VP8()
}
log.Warn().Msg("you are using v2 configuration 'NEKO_VIDEO_CODEC' which is deprecated, please use 'NEKO_CAPTURE_VIDEO_CODEC' instead")
}
if viper.GetBool("vp8") {
s.VideoCodec = codec.VP8()
log.Warn().Msg("you are using deprecated config setting 'NEKO_VP8=true', use 'NEKO_VIDEO_CODEC=vp8' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_VP8=true', use 'NEKO_CAPTURE_VIDEO_CODEC=vp8' instead")
} else if viper.GetBool("vp9") {
s.VideoCodec = codec.VP9()
log.Warn().Msg("you are using deprecated config setting 'NEKO_VP9=true', use 'NEKO_VIDEO_CODEC=vp9' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_VP9=true', use 'NEKO_CAPTURE_VIDEO_CODEC=vp9' instead")
} else if viper.GetBool("h264") {
s.VideoCodec = codec.H264()
log.Warn().Msg("you are using deprecated config setting 'NEKO_H264=true', use 'NEKO_VIDEO_CODEC=h264' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_H264=true', use 'NEKO_CAPTURE_VIDEO_CODEC=h264' instead")
} else if viper.GetBool("av1") {
s.VideoCodec = codec.AV1()
log.Warn().Msg("you are using deprecated config setting 'NEKO_AV1=true', use 'NEKO_VIDEO_CODEC=av1' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_AV1=true', use 'NEKO_CAPTURE_VIDEO_CODEC=av1' instead")
}
videoHWEnc := strings.ToLower(viper.GetString("hwenc"))
switch videoHWEnc {
case "":
fallthrough
case "none":
s.VideoHWEnc = HwEncNone
case "vaapi":
s.VideoHWEnc = HwEncVAAPI
case "nvenc":
s.VideoHWEnc = HwEncNVENC
default:
log.Warn().Str("hwenc", videoHWEnc).Msgf("unknown video hw encoder, using CPU")
videoHWEnc := HwEncUnset
if hwenc := strings.ToLower(viper.GetString("hwenc")); hwenc != "" {
switch hwenc {
case "none":
videoHWEnc = HwEncNone
case "vaapi":
videoHWEnc = HwEncVAAPI
case "nvenc":
videoHWEnc = HwEncNVENC
default:
log.Warn().Str("hwenc", hwenc).Msgf("unknown video hw encoder, using CPU")
}
}
s.VideoBitrate = viper.GetUint("video_bitrate")
s.VideoMaxFPS = int16(viper.GetInt("max_fps"))
s.VideoPipeline = viper.GetString("video")
videoBitrate := viper.GetUint("video_bitrate")
videoMaxFPS := int16(viper.GetInt("max_fps"))
videoPipeline := viper.GetString("video")
// video pipeline
if videoHWEnc != HwEncUnset || videoBitrate != 0 || videoMaxFPS != 0 || videoPipeline != "" {
pipeline, err := NewVideoPipeline(s.VideoCodec, s.Display, videoPipeline, videoMaxFPS, videoBitrate, videoHWEnc)
if err != nil {
log.Warn().Err(err).Msg("unable to create video pipeline, using default")
} else {
s.VideoPipelines = map[string]types.VideoConfig{
"main": {
GstPipeline: pipeline,
},
}
// TODO: add deprecated warning and proper alternative
}
}
//
// audio
//
s.AudioDevice = viper.GetString("device")
if audioDevice := viper.GetString("device"); audioDevice != "" {
s.AudioDevice = audioDevice
log.Warn().Msg("you are using v2 configuration 'NEKO_DEVICE' which is deprecated, please use 'NEKO_CAPTURE_AUDIO_DEVICE' instead")
}
audioCodec := viper.GetString("audio_codec")
s.AudioCodec, ok = codec.ParseStr(audioCodec)
if !ok || s.AudioCodec.Type != webrtc.RTPCodecTypeAudio {
log.Warn().Str("codec", audioCodec).Msgf("unknown audio codec, using Opus")
s.AudioCodec = codec.Opus()
if audioCodec := viper.GetString("audio_codec"); audioCodec != "" {
s.AudioCodec, ok = codec.ParseStr(audioCodec)
if !ok || s.AudioCodec.Type != webrtc.RTPCodecTypeAudio {
log.Warn().Str("codec", audioCodec).Msgf("unknown audio codec, using Opus")
s.AudioCodec = codec.Opus()
}
log.Warn().Msg("you are using v2 configuration 'NEKO_AUDIO_CODEC' which is deprecated, please use 'NEKO_CAPTURE_AUDIO_CODEC' instead")
}
if viper.GetBool("opus") {
s.AudioCodec = codec.Opus()
log.Warn().Msg("you are using deprecated config setting 'NEKO_OPUS=true', use 'NEKO_VIDEO_CODEC=opus' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_OPUS=true', use 'NEKO_CAPTURE_AUDIO_CODEC=opus' instead")
} else if viper.GetBool("g722") {
s.AudioCodec = codec.G722()
log.Warn().Msg("you are using deprecated config setting 'NEKO_G722=true', use 'NEKO_VIDEO_CODEC=g722' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_G722=true', use 'NEKO_CAPTURE_AUDIO_CODEC=g722' instead")
} else if viper.GetBool("pcmu") {
s.AudioCodec = codec.PCMU()
log.Warn().Msg("you are using deprecated config setting 'NEKO_PCMU=true', use 'NEKO_VIDEO_CODEC=pcmu' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_PCMU=true', use 'NEKO_CAPTURE_AUDIO_CODEC=pcmu' instead")
} else if viper.GetBool("pcma") {
s.AudioCodec = codec.PCMA()
log.Warn().Msg("you are using deprecated config setting 'NEKO_PCMA=true', use 'NEKO_VIDEO_CODEC=pcma' instead")
log.Warn().Msg("you are using deprecated config setting 'NEKO_PCMA=true', use 'NEKO_CAPTURE_AUDIO_CODEC=pcma' instead")
}
s.AudioBitrate = viper.GetUint("audio_bitrate")
s.AudioPipeline = viper.GetString("audio")
audioBitrate := viper.GetUint("audio_bitrate")
audioPipeline := viper.GetString("audio")
// audio pipeline
if audioBitrate != 0 || audioPipeline != "" {
pipeline, err := NewAudioPipeline(s.AudioCodec, s.AudioDevice, audioPipeline, audioBitrate)
if err != nil {
log.Warn().Err(err).Msg("unable to create audio pipeline, using default")
} else {
s.AudioPipeline = pipeline
}
// TODO: add deprecated warning and proper alternative
}
//
// broadcast
//
s.BroadcastPipeline = viper.GetString("broadcast_pipeline")
s.BroadcastUrl = viper.GetString("broadcast_url")
s.BroadcastAutostart = viper.GetBool("broadcast_autostart")
if viper.IsSet("broadcast_pipeline") {
s.BroadcastPipeline = viper.GetString("broadcast_pipeline")
log.Warn().Msg("you are using v2 configuration 'NEKO_BROADCAST_PIPELINE' which is deprecated, please use 'NEKO_CAPTURE_BROADCAST_PIPELINE' instead")
}
if viper.IsSet("broadcast_url") {
s.BroadcastUrl = viper.GetString("broadcast_url")
log.Warn().Msg("you are using v2 configuration 'NEKO_BROADCAST_URL' which is deprecated, please use 'NEKO_CAPTURE_BROADCAST_URL' instead")
}
if viper.IsSet("broadcast_autostart") {
s.BroadcastAutostart = viper.GetBool("broadcast_autostart")
log.Warn().Msg("you are using v2 configuration 'NEKO_BROADCAST_AUTOSTART' which is deprecated, please use 'NEKO_CAPTURE_BROADCAST_AUTOSTART' instead")
}
}

View file

@ -1,12 +1,12 @@
package capture
// Legacy pipeline configuration for gstreamer.
package config
import (
"fmt"
"strings"
"m1k1o/neko/internal/capture/gst"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/types/codec"
"m1k1o/neko/pkg/gst"
"m1k1o/neko/pkg/types/codec"
)
/*
@ -34,7 +34,7 @@ const (
audioSrc = "pulsesrc device=%s ! audio/x-raw,channels=2 ! audioconvert ! "
)
func NewBroadcastPipeline(device string, display string, pipelineSrc string, url string) (string, error) {
func NewBroadcastPipeline(device string, display string, pipelineSrc string, url string) string {
video := fmt.Sprintf(videoSrc, display, 25)
audio := fmt.Sprintf(audioSrc, device)
@ -50,10 +50,10 @@ func NewBroadcastPipeline(device string, display string, pipelineSrc string, url
pipelineStr = fmt.Sprintf("flvmux name=mux ! rtmpsink location='%s live=1' %s audio/x-raw,channels=2 ! audioconvert ! voaacenc ! mux. %s x264enc bframes=0 key-int-max=60 byte-stream=true tune=zerolatency speed-preset=veryfast ! mux.", url, audio, video)
}
return pipelineStr, nil
return pipelineStr
}
func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc string, fps int16, bitrate uint, hwenc config.HwEnc) (string, error) {
func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc string, fps int16, bitrate uint, hwenc HwEnc) (string, error) {
pipelineStr := " ! appsink name=appsinkvideo"
// if using custom pipeline
@ -69,7 +69,7 @@ func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc strin
switch rtpCodec.Name {
case codec.VP8().Name:
if hwenc == config.HwEncVAAPI {
if hwenc == HwEncVAAPI {
if err := gst.CheckPlugins([]string{"ximagesrc", "vaapi"}); err != nil {
return "", err
}
@ -144,13 +144,13 @@ func NewVideoPipeline(rtpCodec codec.RTPCodec, display string, pipelineSrc strin
vbvbuf = bitrate
}
if hwenc == config.HwEncVAAPI {
if hwenc == HwEncVAAPI {
if err := gst.CheckPlugins([]string{"vaapi"}); err != nil {
return "", err
}
pipelineStr = fmt.Sprintf(videoSrc+"video/x-raw,format=NV12 ! vaapih264enc rate-control=vbr bitrate=%d keyframe-period=180 quality-level=7 ! video/x-h264,stream-format=byte-stream,profile=constrained-baseline"+pipelineStr, display, fps, bitrate)
} else if hwenc == config.HwEncNVENC {
} else if hwenc == HwEncNVENC {
if err := gst.CheckPlugins([]string{"nvcodec"}); err != nil {
return "", err
}

View file

@ -5,20 +5,67 @@ import (
"regexp"
"strconv"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"m1k1o/neko/pkg/types"
)
type Desktop struct {
Display string
ScreenWidth int
ScreenHeight int
ScreenRate int16
ScreenSize types.ScreenSize
UseInputDriver bool
InputSocket string
Unminimize bool
UploadDrop bool
FileChooserDialog bool
}
func (Desktop) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().String("screen", "1280x720@30", "default screen resolution and framerate")
cmd.PersistentFlags().String("desktop.display", "", "X display to use for desktop sharing")
if err := viper.BindPFlag("desktop.display", cmd.PersistentFlags().Lookup("desktop.display")); err != nil {
return err
}
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 (Desktop) InitV2(cmd *cobra.Command) error {
cmd.PersistentFlags().String("screen", "", "V2: default screen resolution and framerate")
if err := viper.BindPFlag("screen", cmd.PersistentFlags().Lookup("screen")); err != nil {
return err
}
@ -27,15 +74,21 @@ func (Desktop) Init(cmd *cobra.Command) error {
}
func (s *Desktop) Set() {
// Display is provided by env variable
s.Display = os.Getenv("DISPLAY")
s.Display = viper.GetString("desktop.display")
s.ScreenWidth = 1280
s.ScreenHeight = 720
s.ScreenRate = 30
// Display is provided by env variable unless explicitly set
if s.Display == "" {
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("screen"))
res := r.FindStringSubmatch(viper.GetString("desktop.screen"))
if len(res) > 0 {
width, err1 := strconv.ParseInt(res[1], 10, 64)
@ -43,9 +96,35 @@ func (s *Desktop) Set() {
rate, err3 := strconv.ParseInt(res[3], 10, 64)
if err1 == nil && err2 == nil && err3 == nil {
s.ScreenWidth = int(width)
s.ScreenHeight = int(height)
s.ScreenRate = int16(rate)
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")
}
func (s *Desktop) SetV2() {
if viper.IsSet("screen") {
r := regexp.MustCompile(`([0-9]{1,4})x([0-9]{1,4})@([0-9]{1,3})`)
res := r.FindStringSubmatch(viper.GetString("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)
}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_SCREEN' which is deprecated, please use 'NEKO_DESKTOP_SCREEN' instead")
}
}

View file

@ -0,0 +1,159 @@
package config
import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"m1k1o/neko/internal/member/file"
"m1k1o/neko/internal/member/multiuser"
"m1k1o/neko/internal/member/object"
"m1k1o/neko/pkg/types"
"m1k1o/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 (Member) InitV2(cmd *cobra.Command) error {
cmd.PersistentFlags().String("password", "", "V2: password for connecting to stream")
if err := viper.BindPFlag("password", cmd.PersistentFlags().Lookup("password")); err != nil {
return err
}
cmd.PersistentFlags().String("password_admin", "", "V2: admin password for connecting to stream")
if err := viper.BindPFlag("password_admin", cmd.PersistentFlags().Lookup("password_admin")); 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")
}
}
func (s *Member) SetV2() {
if viper.IsSet("password") || viper.IsSet("password_admin") {
s.Provider = "multiuser"
if userPassword := viper.GetString("password"); userPassword != "" {
s.Multiuser.UserPassword = userPassword
} else {
s.Multiuser.UserPassword = "neko"
}
if adminPassword := viper.GetString("password_admin"); adminPassword != "" {
s.Multiuser.AdminPassword = adminPassword
} else {
s.Multiuser.AdminPassword = "admin"
}
log.Warn().Msg("you are using v2 configuration 'NEKO_PASSWORD' and 'NEKO_PASSWORD_ADMIN' which are deprecated, please use 'NEKO_MEMBER_MULTIUSER_USER_PASSWORD' and 'NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD' with 'NEKO_MEMBER_PROVIDER=multiuser' instead")
}
}

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")
}

View file

@ -1,29 +1,69 @@
package config
import (
"os"
"path/filepath"
"runtime"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Root struct {
Debug bool
Logs bool
CfgFile string
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().BoolP("logs", "l", false, "save logs to file")
if err := viper.BindPFlag("logs", cmd.PersistentFlags().Lookup("logs")); err != nil {
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("config", "", "configuration file path")
if err := viper.BindPFlag("config", cmd.PersistentFlags().Lookup("config")); err != nil {
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 (Root) InitV2(cmd *cobra.Command) error {
cmd.PersistentFlags().BoolP("logs", "l", false, "V2: save logs to file")
if err := viper.BindPFlag("logs", cmd.PersistentFlags().Lookup("logs")); err != nil {
return err
}
@ -31,7 +71,53 @@ func (Root) Init(cmd *cobra.Command) error {
}
func (s *Root) Set() {
s.Logs = viper.GetBool("logs")
s.Debug = viper.GetBool("debug")
s.CfgFile = viper.GetString("config")
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
}
}
func (s *Root) SetV2() {
if viper.IsSet("logs") {
if viper.GetBool("logs") {
logs := filepath.Join(".", "logs")
if runtime.GOOS == "linux" {
logs = "/var/log/neko"
}
s.LogDir = logs
} else {
s.LogDir = ""
}
log.Warn().Msg("you are using v2 configuration 'NEKO_LOGS' which is deprecated, please use 'NEKO_LOG_DIR=/path/to/logs' instead")
}
}

View file

@ -1,13 +1,13 @@
package config
import (
"net/http"
"path"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"m1k1o/neko/internal/utils"
"m1k1o/neko/pkg/utils"
)
type Server struct {
@ -17,41 +17,92 @@ type Server struct {
Proxy bool
Static string
PathPrefix string
PProf bool
Metrics bool
CORS []string
}
func (Server) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().String("bind", "127.0.0.1:8080", "address/port/socket to serve neko")
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 (Server) InitV2(cmd *cobra.Command) error {
cmd.PersistentFlags().String("bind", "", "V2: address/port/socket to serve neko")
if err := viper.BindPFlag("bind", cmd.PersistentFlags().Lookup("bind")); err != nil {
return err
}
cmd.PersistentFlags().String("cert", "", "path to the SSL cert used to secure the neko server")
cmd.PersistentFlags().String("cert", "", "V2: path to the SSL cert used to secure the neko server")
if err := viper.BindPFlag("cert", cmd.PersistentFlags().Lookup("cert")); err != nil {
return err
}
cmd.PersistentFlags().String("key", "", "path to the SSL key used to secure the neko server")
cmd.PersistentFlags().String("key", "", "V2: path to the SSL key used to secure the neko server")
if err := viper.BindPFlag("key", cmd.PersistentFlags().Lookup("key")); err != nil {
return err
}
cmd.PersistentFlags().Bool("proxy", false, "enable reverse proxy mode")
cmd.PersistentFlags().Bool("proxy", false, "V2: enable reverse proxy mode")
if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil {
return err
}
cmd.PersistentFlags().String("static", "./www", "path to neko client files to serve")
cmd.PersistentFlags().String("static", "", "V2: path to neko client files to serve")
if err := viper.BindPFlag("static", cmd.PersistentFlags().Lookup("static")); err != nil {
return err
}
cmd.PersistentFlags().String("path_prefix", "/", "path prefix for HTTP requests")
cmd.PersistentFlags().String("path_prefix", "", "V2: path prefix for HTTP requests")
if err := viper.BindPFlag("path_prefix", cmd.PersistentFlags().Lookup("path_prefix")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("cors", []string{"*"}, "list of allowed origins for CORS")
cmd.PersistentFlags().StringSlice("cors", []string{}, "V2: list of allowed origins for CORS")
if err := viper.BindPFlag("cors", cmd.PersistentFlags().Lookup("cors")); err != nil {
return err
}
@ -60,21 +111,68 @@ func (Server) Init(cmd *cobra.Command) error {
}
func (s *Server) Set() {
s.Cert = viper.GetString("cert")
s.Key = viper.GetString("key")
s.Bind = viper.GetString("bind")
s.Proxy = viper.GetBool("proxy")
s.Static = viper.GetString("static")
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
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("cors")
s.CORS = viper.GetStringSlice("server.cors")
in, _ := utils.ArrayIn("*", s.CORS)
if len(s.CORS) == 0 || in {
s.CORS = []string{"*"}
}
}
func (s *Server) AllowOrigin(r *http.Request, origin string) bool {
func (s *Server) SetV2() {
if viper.IsSet("cert") {
s.Cert = viper.GetString("cert")
log.Warn().Msg("you are using v2 configuration 'NEKO_CERT' which is deprecated, please use 'NEKO_SERVER_CERT' instead")
}
if viper.IsSet("key") {
s.Key = viper.GetString("key")
log.Warn().Msg("you are using v2 configuration 'NEKO_KEY' which is deprecated, please use 'NEKO_SERVER_KEY' instead")
}
if viper.IsSet("bind") {
s.Bind = viper.GetString("bind")
log.Warn().Msg("you are using v2 configuration 'NEKO_BIND' which is deprecated, please use 'NEKO_SERVER_BIND' instead")
}
if viper.IsSet("proxy") {
s.Proxy = viper.GetBool("proxy")
log.Warn().Msg("you are using v2 configuration 'NEKO_PROXY' which is deprecated, please use 'NEKO_SERVER_PROXY' instead")
}
if viper.IsSet("static") {
s.Static = viper.GetString("static")
log.Warn().Msg("you are using v2 configuration 'NEKO_STATIC' which is deprecated, please use 'NEKO_SERVER_STATIC' instead")
}
if viper.IsSet("path_prefix") {
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
log.Warn().Msg("you are using v2 configuration 'NEKO_PATH_PREFIX' which is deprecated, please use 'NEKO_SERVER_PATH_PREFIX' instead")
}
if viper.IsSet("cors") {
s.CORS = viper.GetStringSlice("cors")
in, _ := utils.ArrayIn("*", s.CORS)
if len(s.CORS) == 0 || in {
s.CORS = []string{"*"}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_CORS' which is deprecated, please use 'NEKO_SERVER_CORS' instead")
}
}
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] == "*"
}

View file

@ -0,0 +1,159 @@
package config
import (
"time"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Session struct {
File string
PrivateMode bool
LockedLogins bool
LockedControls bool
ControlProtection 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_logins", false, "whether logins should be locked for users initially")
if err := viper.BindPFlag("session.locked_logins", cmd.PersistentFlags().Lookup("session.locked_logins")); 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.control_protection", false, "users can gain control only if at least one admin is in the room")
if err := viper.BindPFlag("session.control_protection", cmd.PersistentFlags().Lookup("session.control_protection")); 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 (Session) InitV2(cmd *cobra.Command) error {
cmd.PersistentFlags().StringSlice("locks", []string{}, "V2: resources, that will be locked when starting (control, login)")
if err := viper.BindPFlag("locks", cmd.PersistentFlags().Lookup("locks")); err != nil {
return err
}
cmd.PersistentFlags().Bool("control_protection", false, "V2: control protection means, users can gain control only if at least one admin is in the room")
if err := viper.BindPFlag("control_protection", cmd.PersistentFlags().Lookup("control_protection")); err != nil {
return err
}
cmd.PersistentFlags().Bool("implicit_control", false, "V2: if enabled members can gain control implicitly")
if err := viper.BindPFlag("implicit_control", cmd.PersistentFlags().Lookup("implicit_control")); err != nil {
return err
}
return nil
}
func (s *Session) Set() {
s.File = viper.GetString("session.file")
s.PrivateMode = viper.GetBool("session.private_mode")
s.LockedLogins = viper.GetBool("session.locked_logins")
s.LockedControls = viper.GetBool("session.locked_controls")
s.ControlProtection = viper.GetBool("session.control_protection")
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")
}
func (s *Session) SetV2() {
if viper.IsSet("locks") {
locks := viper.GetStringSlice("locks")
for _, lock := range locks {
switch lock {
// TODO: file_transfer
case "control":
s.LockedControls = true
case "login":
s.LockedLogins = true
}
}
log.Warn().Msg("you are using v2 configuration 'NEKO_LOCKS' which is deprecated, please use 'NEKO_SESSION_LOCKED_CONTROLS' and 'NEKO_SESSION_LOCKED_LOGINS' instead")
}
if viper.IsSet("implicit_control") {
s.ImplicitHosting = viper.GetBool("implicit_control")
log.Warn().Msg("you are using v2 configuration 'NEKO_IMPLICIT_CONTROL' which is deprecated, please use 'NEKO_SESSION_IMPLICIT_HOSTING' instead")
}
if viper.IsSet("control_protection") {
s.ControlProtection = viper.GetBool("control_protection")
log.Warn().Msg("you are using v2 configuration 'NEKO_CONTROL_PROTECTION' which is deprecated, please use 'NEKO_SESSION_CONTROL_PROTECTION' instead")
}
}

View file

@ -4,131 +4,396 @@ import (
"encoding/json"
"strconv"
"strings"
"m1k1o/neko/internal/utils"
"time"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/pion/webrtc/v3"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/utils"
)
type WebRTC struct {
ICELite bool
ICEServers []webrtc.ICEServer
EphemeralMin uint16
EphemeralMax uint16
NAT1To1IPs []string
TCPMUX int
UDPMUX int
// default stun server
const defStunSrv = "stun:stun.l.google.com:19302"
ImplicitControl bool
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().String("epr", "59000-59100", "limits the pool of ephemeral ports that ICE UDP connections can allocate from")
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 (WebRTC) InitV2(cmd *cobra.Command) error {
cmd.PersistentFlags().String("epr", "", "V2: limits the pool of ephemeral ports that ICE UDP connections can allocate from")
if err := viper.BindPFlag("epr", cmd.PersistentFlags().Lookup("epr")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("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")
cmd.PersistentFlags().StringSlice("nat1to1", []string{}, "V2: 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("nat1to1", cmd.PersistentFlags().Lookup("nat1to1")); err != nil {
return err
}
cmd.PersistentFlags().Int("tcpmux", 0, "single TCP mux port for all peers")
cmd.PersistentFlags().Int("tcpmux", 0, "V2: single TCP mux port for all peers")
if err := viper.BindPFlag("tcpmux", cmd.PersistentFlags().Lookup("tcpmux")); err != nil {
return err
}
cmd.PersistentFlags().Int("udpmux", 0, "single UDP mux port for all peers")
cmd.PersistentFlags().Int("udpmux", 0, "V2: single UDP mux port for all peers")
if err := viper.BindPFlag("udpmux", cmd.PersistentFlags().Lookup("udpmux")); err != nil {
return err
}
cmd.PersistentFlags().String("ipfetch", "http://checkip.amazonaws.com", "automatically fetch IP address from given URL when nat1to1 is not present")
cmd.PersistentFlags().String("ipfetch", "", "V2: automatically fetch IP address from given URL when nat1to1 is not present")
if err := viper.BindPFlag("ipfetch", cmd.PersistentFlags().Lookup("ipfetch")); err != nil {
return err
}
cmd.PersistentFlags().Bool("icelite", false, "configures whether or not the ice agent should be a lite agent")
cmd.PersistentFlags().Bool("icelite", false, "V2: configures whether or not the ice agent should be a lite agent")
if err := viper.BindPFlag("icelite", cmd.PersistentFlags().Lookup("icelite")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("iceserver", []string{"stun:stun.l.google.com:19302"}, "describes a single STUN and TURN server that can be used by the ICEAgent to establish a connection with a peer")
cmd.PersistentFlags().StringSlice("iceserver", []string{}, "V2: describes a single STUN and TURN server that can be used by the ICEAgent to establish a connection with a peer")
if err := viper.BindPFlag("iceserver", cmd.PersistentFlags().Lookup("iceserver")); err != nil {
return err
}
cmd.PersistentFlags().String("iceservers", "", "describes a single STUN and TURN server that can be used by the ICEAgent to establish a connection with a peer")
cmd.PersistentFlags().String("iceservers", "", "V2: describes a single STUN and TURN server that can be used by the ICEAgent to establish a connection with a peer")
if err := viper.BindPFlag("iceservers", cmd.PersistentFlags().Lookup("iceservers")); err != nil {
return err
}
// TODO: Should be moved to session config.
cmd.PersistentFlags().Bool("implicit_control", false, "if enabled members can gain control implicitly")
if err := viper.BindPFlag("implicit_control", cmd.PersistentFlags().Lookup("implicit_control")); err != nil {
return err
}
return nil
}
func (s *WebRTC) Set() {
s.NAT1To1IPs = viper.GetStringSlice("nat1to1")
s.TCPMUX = viper.GetInt("tcpmux")
s.UDPMUX = viper.GetInt("udpmux")
s.ICELite = viper.GetBool("icelite")
s.ICEServers = []webrtc.ICEServer{}
s.ICELite = viper.GetBool("webrtc.icelite")
s.ICETrickle = viper.GetBool("webrtc.icetrickle")
iceServersJson := viper.GetString("iceservers")
if iceServersJson != "" {
err := json.Unmarshal([]byte(iceServersJson), &s.ICEServers)
if err != nil {
log.Panic().Err(err).Msg("failed to process iceservers")
// 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")
}
}
iceServerSlice := viper.GetStringSlice("iceserver")
if len(iceServerSlice) > 0 {
s.ICEServers = append(s.ICEServers, webrtc.ICEServer{URLs: iceServerSlice})
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")
}
if len(s.NAT1To1IPs) == 0 {
ipfetch := viper.GetString("ipfetch")
ip, err := utils.GetIP(ipfetch)
if err != nil {
log.Panic().Err(err).Str("ipfetch", ipfetch).Msg("failed to fetch ip address")
}
s.NAT1To1IPs = append(s.NAT1To1IPs, ip)
}
min := uint16(59000)
max := uint16(59100)
epr := viper.GetString("epr")
ports := strings.SplitN(epr, "-", -1)
if len(ports) > 1 {
start, err := strconv.ParseUint(ports[0], 10, 16)
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 {
min = uint16(start)
}
end, err := strconv.ParseUint(ports[1], 10, 16)
if err == nil {
max = uint16(end)
s.NAT1To1IPs = append(s.NAT1To1IPs, ip)
} else {
log.Warn().Err(err).Msgf("IP retrieval failed")
}
}
if min > max {
s.EphemeralMin = max
s.EphemeralMax = min
} else {
s.EphemeralMin = min
s.EphemeralMax = max
}
// bandwidth estimator
// TODO: Should be moved to session config.
s.ImplicitControl = viper.GetBool("implicit_control")
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")
}
func (s *WebRTC) SetV2() {
if viper.IsSet("nat1to1") {
s.NAT1To1IPs = viper.GetStringSlice("nat1to1")
log.Warn().Msg("you are using v2 configuration 'NEKO_NAT1TO1' which is deprecated, please use 'NEKO_WEBRTC_NAT1TO1' instead")
}
if viper.IsSet("tcpmux") {
s.TCPMux = viper.GetInt("tcpmux")
log.Warn().Msg("you are using v2 configuration 'NEKO_TCPMUX' which is deprecated, please use 'NEKO_WEBRTC_TCPMUX' instead")
}
if viper.IsSet("udpmux") {
s.UDPMux = viper.GetInt("udpmux")
log.Warn().Msg("you are using v2 configuration 'NEKO_UDPMUX' which is deprecated, please use 'NEKO_WEBRTC_UDPMUX' instead")
}
if viper.IsSet("icelite") {
s.ICELite = viper.GetBool("icelite")
log.Warn().Msg("you are using v2 configuration 'NEKO_ICELITE' which is deprecated, please use 'NEKO_WEBRTC_ICELITE' instead")
}
if viper.IsSet("iceservers") {
iceServers := []types.ICEServer{}
iceServersJson := viper.GetString("iceservers")
if iceServersJson != "" {
err := json.Unmarshal([]byte(iceServersJson), &iceServers)
if err != nil {
log.Panic().Err(err).Msg("failed to process iceservers")
}
}
s.ICEServersFrontend = iceServers
s.ICEServersBackend = iceServers
log.Warn().Msg("you are using v2 configuration 'NEKO_ICESERVERS' which is deprecated, please use 'NEKO_WEBRTC_ICESERVERS_FRONTEND' and/or 'NEKO_WEBRTC_ICESERVERS_BACKEND' instead")
}
if viper.IsSet("iceserver") {
iceServerSlice := viper.GetStringSlice("iceserver")
if len(iceServerSlice) > 0 {
s.ICEServersFrontend = append(s.ICEServersFrontend, types.ICEServer{URLs: iceServerSlice})
s.ICEServersBackend = append(s.ICEServersBackend, types.ICEServer{URLs: iceServerSlice})
}
log.Warn().Msg("you are using v2 configuration 'NEKO_ICESERVER' which is deprecated, please use 'NEKO_WEBRTC_ICESERVERS_FRONTEND' and/or 'NEKO_WEBRTC_ICESERVERS_BACKEND' instead")
}
if viper.IsSet("ipfetch") {
if len(s.NAT1To1IPs) == 0 {
ipfetch := viper.GetString("ipfetch")
ip, err := utils.HttpRequestGET(ipfetch)
if err != nil {
log.Panic().Err(err).Str("ipfetch", ipfetch).Msg("failed to fetch ip address")
}
s.NAT1To1IPs = append(s.NAT1To1IPs, ip)
}
log.Warn().Msg("you are using v2 configuration 'NEKO_IPFETCH' which is deprecated, please use 'NEKO_WEBRTC_IP_RETRIEVAL_URL' instead")
}
if viper.IsSet("epr") {
min := uint16(59000)
max := uint16(59100)
epr := viper.GetString("epr")
ports := strings.SplitN(epr, "-", -1)
if len(ports) > 1 {
start, err := strconv.ParseUint(ports[0], 10, 16)
if err == nil {
min = uint16(start)
}
end, err := strconv.ParseUint(ports[1], 10, 16)
if err == nil {
max = uint16(end)
}
}
if min > max {
s.EphemeralMin = max
s.EphemeralMax = min
} else {
s.EphemeralMin = min
s.EphemeralMax = max
}
log.Warn().Msg("you are using v2 configuration 'NEKO_EPR' which is deprecated, please use 'NEKO_WEBRTC_EPR' instead")
}
}

View file

@ -1,67 +0,0 @@
package config
import (
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type WebSocket struct {
Password string
AdminPassword string
Locks []string
ControlProtection bool
FileTransferEnabled bool
FileTransferPath string
}
func (WebSocket) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().String("password", "neko", "password for connecting to stream")
if err := viper.BindPFlag("password", cmd.PersistentFlags().Lookup("password")); err != nil {
return err
}
cmd.PersistentFlags().String("password_admin", "admin", "admin password for connecting to stream")
if err := viper.BindPFlag("password_admin", cmd.PersistentFlags().Lookup("password_admin")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("locks", []string{}, "resources, that will be locked when starting (control, login)")
if err := viper.BindPFlag("locks", cmd.PersistentFlags().Lookup("locks")); err != nil {
return err
}
cmd.PersistentFlags().Bool("control_protection", false, "control protection means, users can gain control only if at least one admin is in the room")
if err := viper.BindPFlag("control_protection", cmd.PersistentFlags().Lookup("control_protection")); err != nil {
return err
}
// File transfer
cmd.PersistentFlags().Bool("file_transfer_enabled", false, "enable file transfer feature")
if err := viper.BindPFlag("file_transfer_enabled", cmd.PersistentFlags().Lookup("file_transfer_enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("file_transfer_path", "/home/neko/Downloads", "path to use for file transfer")
if err := viper.BindPFlag("file_transfer_path", cmd.PersistentFlags().Lookup("file_transfer_path")); err != nil {
return err
}
return nil
}
func (s *WebSocket) Set() {
s.Password = viper.GetString("password")
s.AdminPassword = viper.GetString("password_admin")
s.Locks = viper.GetStringSlice("locks")
s.ControlProtection = viper.GetBool("control_protection")
s.FileTransferEnabled = viper.GetBool("file_transfer_enabled")
s.FileTransferPath = viper.GetString("file_transfer_path")
s.FileTransferPath = filepath.Clean(s.FileTransferPath)
}

View file

@ -1,11 +1,122 @@
package desktop
import "m1k1o/neko/internal/desktop/clipboard"
import (
"bytes"
"fmt"
"os/exec"
"strings"
func (manager *DesktopManagerCtx) ReadClipboard() string {
return clipboard.Read()
"m1k1o/neko/pkg/types"
"m1k1o/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) WriteClipboard(data string) {
clipboard.Write(data)
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
}

View file

@ -1,21 +0,0 @@
#include "clipboard.h"
static clipboard_c *CLIPBOARD = NULL;
clipboard_c *getClipboard(void) {
if (CLIPBOARD == NULL) {
CLIPBOARD = clipboard_new(NULL);
}
return CLIPBOARD;
}
void ClipboardSet(char *src) {
clipboard_c *cb = getClipboard();
clipboard_set_text_ex(cb, src, strlen(src), 0);
}
char *ClipboardGet() {
clipboard_c *cb = getClipboard();
return clipboard_text_ex(cb, NULL, 0);
}

View file

@ -1,35 +0,0 @@
package clipboard
/*
#cgo linux LDFLAGS: /usr/local/lib/libclipboard.a -lxcb
#include "clipboard.h"
*/
import "C"
import (
"sync"
"unsafe"
)
var mu sync.Mutex
func Read() string {
mu.Lock()
defer mu.Unlock()
clipboardUnsafe := C.ClipboardGet()
defer C.free(unsafe.Pointer(clipboardUnsafe))
return C.GoString(clipboardUnsafe)
}
func Write(data string) {
mu.Lock()
defer mu.Unlock()
clipboardUnsafe := C.CString(data)
defer C.free(unsafe.Pointer(clipboardUnsafe))
C.ClipboardSet(clipboardUnsafe)
}

View file

@ -1,9 +0,0 @@
#pragma once
#include <libclipboard.h>
#include <string.h>
clipboard_c *getClipboard(void);
void ClipboardSet(char *src);
char *ClipboardGet();

View file

@ -0,0 +1,68 @@
package desktop
import (
"time"
"m1k1o/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"
"m1k1o/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
}

View file

@ -1,36 +1,47 @@
package desktop
import (
"fmt"
"sync"
"time"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/desktop/xevent"
"m1k1o/neko/internal/desktop/xorg"
"github.com/kataras/go-events"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko/internal/config"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/xevent"
"m1k1o/neko/pkg/xinput"
"m1k1o/neko/pkg/xorg"
)
var mu = sync.Mutex{}
type DesktopManagerCtx struct {
logger zerolog.Logger
wg sync.WaitGroup
shutdown chan struct{}
config *config.Desktop
screenSizeChangeChannel chan bool
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 {
return &DesktopManagerCtx{
logger: log.With().Str("module", "desktop").Logger(),
shutdown: make(chan struct{}),
config: config,
var input xinput.Driver
if config.UseInputDriver {
input = xinput.NewDriver(config.InputSocket)
} else {
input = xinput.NewDummy()
}
screenSizeChangeChannel: make(chan bool),
return &DesktopManagerCtx{
logger: log.With().Str("module", "desktop").Logger(),
shutdown: make(chan struct{}),
emmiter: events.New(),
config: config,
screenSize: config.ScreenSize,
input: input,
}
}
@ -39,31 +50,48 @@ func (manager *DesktopManagerCtx) Start() {
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()
err := xorg.ChangeScreenSize(manager.config.ScreenWidth, manager.config.ScreenHeight, manager.config.ScreenRate)
manager.logger.Err(err).
Str("screen_size", fmt.Sprintf("%dx%d@%d", manager.config.ScreenWidth, manager.config.ScreenHeight, manager.config.ScreenRate)).
Msgf("setting initial screen size")
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)
go func() {
for {
msg, ok := <-xevent.EventErrorChannel
if !ok {
manager.logger.Info().Msg("xevent error channel was closed")
return
}
// in case it was opened
if manager.config.FileChooserDialog {
go manager.CloseFileChooserDialog()
}
manager.logger.Warn().
Uint8("error_code", msg.Error_code).
Str("message", msg.Message).
Uint8("request_code", msg.Request_code).
Uint8("minor_code", msg.Minor_code).
Msg("X event error occurred")
}
}()
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)
@ -73,26 +101,36 @@ func (manager *DesktopManagerCtx) Start() {
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(time.Second * 10)
xorg.CheckKeys(debounceDuration)
manager.input.Debounce(debounceDuration)
}
}
}()
}
func (manager *DesktopManagerCtx) GetScreenSizeChangeChannel() chan bool {
return manager.screenSizeChangeChannel
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("desktop shutting down")
manager.logger.Info().Msgf("shutdown")
close(manager.shutdown)
close(manager.screenSizeChangeChannel)
manager.wg.Wait()
xorg.DisplayClose()

View file

@ -1,18 +1,35 @@
package desktop
import (
"m1k1o/neko/internal/desktop/xevent"
"m1k1o/neko/internal/types"
"m1k1o/neko/pkg/xevent"
)
func (manager *DesktopManagerCtx) GetCursorChangedChannel() chan uint64 {
return xevent.CursorChangedChannel
func (manager *DesktopManagerCtx) OnCursorChanged(listener func(serial uint64)) {
xevent.Emmiter.On("cursor-changed", func(payload ...any) {
listener(payload[0].(uint64))
})
}
func (manager *DesktopManagerCtx) GetClipboardUpdatedChannel() chan struct{} {
return xevent.ClipboardUpdatedChannel
func (manager *DesktopManagerCtx) OnClipboardUpdated(listener func()) {
xevent.Emmiter.On("clipboard-updated", func(payload ...any) {
listener()
})
}
func (manager *DesktopManagerCtx) GetEventErrorChannel() chan types.DesktopErrorMessage {
return xevent.EventErrorChannel
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

@ -1,81 +0,0 @@
#include "xevent.h"
static int XEventError(Display *display, XErrorEvent *event) {
char message[100];
int error;
error = XGetErrorText(display, event->error_code, message, sizeof(message));
if (error) {
goXEventError(event, "Could not get error message.");
} else {
goXEventError(event, message);
}
return 1;
}
void XEventLoop(char *name) {
Display *display = XOpenDisplay(name);
Window root = RootWindow(display, 0);
int xfixes_event_base, xfixes_error_base;
if (!XFixesQueryExtension(display, &xfixes_event_base, &xfixes_error_base)) {
return;
}
Atom WM_WINDOW_ROLE = XInternAtom(display, "WM_WINDOW_ROLE", 1);
Atom XA_CLIPBOARD = XInternAtom(display, "CLIPBOARD", 0);
XFixesSelectSelectionInput(display, root, XA_CLIPBOARD, XFixesSetSelectionOwnerNotifyMask);
XFixesSelectCursorInput(display, root, XFixesDisplayCursorNotifyMask);
XSelectInput(display, root, SubstructureNotifyMask);
XSync(display, 0);
XSetErrorHandler(XEventError);
while (goXEventActive()) {
XEvent event;
XNextEvent(display, &event);
// XFixesDisplayCursorNotify
if (event.type == xfixes_event_base + 1) {
XFixesCursorNotifyEvent notifyEvent = *((XFixesCursorNotifyEvent *) &event);
if (notifyEvent.subtype == XFixesDisplayCursorNotify) {
goXEventCursorChanged(notifyEvent);
continue;
}
}
// XFixesSelectionNotifyEvent
if (event.type == xfixes_event_base + XFixesSelectionNotify) {
XFixesSelectionNotifyEvent notifyEvent = *((XFixesSelectionNotifyEvent *) &event);
if (notifyEvent.subtype == XFixesSetSelectionOwnerNotify && notifyEvent.selection == XA_CLIPBOARD) {
goXEventClipboardUpdated();
continue;
}
}
// ConfigureNotify
if (event.type == ConfigureNotify) {
Window window = event.xconfigure.window;
char *name;
XFetchName(display, window, &name);
XTextProperty role;
XGetTextProperty(display, window, &role, WM_WINDOW_ROLE);
goXEventConfigureNotify(display, window, name, role.value);
XFree(name);
continue;
}
// UnmapNotify
if (event.type == UnmapNotify) {
Window window = event.xunmap.window;
goXEventUnmapNotify(window);
continue;
}
}
XCloseDisplay(display);
}

View file

@ -1,78 +0,0 @@
package xevent
/*
#cgo LDFLAGS: -lX11 -lXfixes
#include "xevent.h"
*/
import "C"
import (
"unsafe"
"m1k1o/neko/internal/types"
)
var CursorChangedChannel chan uint64
var ClipboardUpdatedChannel chan struct{}
var EventErrorChannel chan types.DesktopErrorMessage
func init() {
CursorChangedChannel = make(chan uint64)
ClipboardUpdatedChannel = make(chan struct{})
EventErrorChannel = make(chan types.DesktopErrorMessage)
go func() {
for {
// TODO: Reserved for future use.
<-CursorChangedChannel
}
}()
}
func EventLoop(display string) {
displayUnsafe := C.CString(display)
defer C.free(unsafe.Pointer(displayUnsafe))
C.XEventLoop(displayUnsafe)
}
// TODO: Shutdown function.
//close(CursorChangedChannel)
//close(ClipboardUpdatedChannel)
//close(EventErrorChannel)
//export goXEventCursorChanged
func goXEventCursorChanged(event C.XFixesCursorNotifyEvent) {
CursorChangedChannel <- uint64(event.cursor_serial)
}
//export goXEventClipboardUpdated
func goXEventClipboardUpdated() {
ClipboardUpdatedChannel <- struct{}{}
}
//export goXEventConfigureNotify
func goXEventConfigureNotify(display *C.Display, window C.Window, name *C.char, role *C.char) {
}
//export goXEventUnmapNotify
func goXEventUnmapNotify(window C.Window) {
}
//export goXEventError
func goXEventError(event *C.XErrorEvent, message *C.char) {
EventErrorChannel <- types.DesktopErrorMessage{
Error_code: uint8(event.error_code),
Message: C.GoString(message),
Request_code: uint8(event.request_code),
Minor_code: uint8(event.minor_code),
}
}
//export goXEventActive
func goXEventActive() C.int {
return C.int(1)
}

View file

@ -0,0 +1,36 @@
package desktop
import "m1k1o/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)
}

View file

@ -6,8 +6,8 @@ import (
"regexp"
"time"
"m1k1o/neko/internal/desktop/xorg"
"m1k1o/neko/internal/types"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/xorg"
)
func (manager *DesktopManagerCtx) Move(x, y int) {
@ -18,8 +18,8 @@ func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) {
return xorg.GetCursorPosition()
}
func (manager *DesktopManagerCtx) Scroll(x, y int) {
xorg.Scroll(x, y)
func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) {
xorg.Scroll(deltaX, deltaY, controlKey)
}
func (manager *DesktopManagerCtx) ButtonDown(code uint32) error {
@ -66,23 +66,44 @@ func (manager *DesktopManagerCtx) ResetKeys() {
xorg.ResetKeys()
}
func (manager *DesktopManagerCtx) ScreenConfigurations() map[int]types.ScreenConfiguration {
return xorg.ScreenConfigurations
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(size types.ScreenSize) error {
func (manager *DesktopManagerCtx) SetScreenSize(screenSize types.ScreenSize) (types.ScreenSize, error) {
mu.Lock()
manager.GetScreenSizeChangeChannel() <- true
manager.emmiter.Emit("before_screen_size_change")
defer func() {
manager.GetScreenSizeChangeChannel() <- false
manager.emmiter.Emit("after_screen_size_change")
mu.Unlock()
}()
return xorg.ChangeScreenSize(size.Width, size.Height, size.Rate)
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 {
func (manager *DesktopManagerCtx) GetScreenSize() types.ScreenSize {
return xorg.GetScreenSize()
}
@ -119,24 +140,56 @@ func (manager *DesktopManagerCtx) GetKeyboardMap() (*types.KeyboardMap, error) {
}
func (manager *DesktopManagerCtx) SetKeyboardModifiers(mod types.KeyboardModifiers) {
if mod.NumLock != nil {
xorg.SetKeyboardModifier(xorg.KbdModNumLock, *mod.NumLock)
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()
NumLock := (modifiers & xorg.KbdModNumLock) != 0
CapsLock := (modifiers & xorg.KbdModCapsLock) != 0
isset := func(mod xorg.KbdMod) *bool {
x := modifiers&mod != 0
return &x
}
return types.KeyboardModifiers{
NumLock: &NumLock,
CapsLock: &CapsLock,
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),
}
}

View file

@ -0,0 +1,123 @@
package http
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"m1k1o/neko/pkg/types"
"m1k1o/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
}

View file

@ -0,0 +1,36 @@
package http
import (
"net/http"
"net/http/pprof"
"github.com/go-chi/chi"
"m1k1o/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
})
}

View file

@ -1,250 +0,0 @@
package http
import (
"context"
"encoding/json"
"fmt"
"image/jpeg"
"io"
"net/http"
"os"
"regexp"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/types"
)
const FILE_UPLOAD_BUF_SIZE = 65000
type Server struct {
logger zerolog.Logger
router *chi.Mux
http *http.Server
conf *config.Server
}
func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop types.DesktopManager) *Server {
logger := log.With().Str("module", "http").Logger()
router := chi.NewRouter()
router.Use(middleware.RequestID) // Create a request ID for each request
if conf.Proxy {
router.Use(middleware.RealIP)
}
router.Use(middleware.RequestLogger(&logformatter{logger}))
router.Use(middleware.Recoverer) // Recover from panics without crashing server
router.Use(middleware.Compress(5, "application/octet-stream"))
router.Use(cors.Handler(cors.Options{
AllowOriginFunc: conf.AllowOrigin,
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
}))
if conf.PathPrefix != "/" {
router.Use(func(h http.Handler) http.Handler {
return http.StripPrefix(conf.PathPrefix, h)
})
}
router.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
err := webSocketHandler.Upgrade(w, r)
if err != nil {
logger.Warn().Err(err).Msg("failed to upgrade websocket conection")
}
})
router.Get("/stats", func(w http.ResponseWriter, r *http.Request) {
password := r.URL.Query().Get("pwd")
isAdmin, err := webSocketHandler.IsAdmin(password)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !isAdmin {
http.Error(w, "bad authorization", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
stats := webSocketHandler.Stats()
if err := json.NewEncoder(w).Encode(stats); err != nil {
logger.Warn().Err(err).Msg("failed writing json error response")
}
})
router.Get("/screenshot.jpg", func(w http.ResponseWriter, r *http.Request) {
password := r.URL.Query().Get("pwd")
isAdmin, err := webSocketHandler.IsAdmin(password)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !isAdmin {
http.Error(w, "bad authorization", http.StatusUnauthorized)
return
}
if webSocketHandler.IsLocked("login") {
http.Error(w, "room is locked", http.StatusLocked)
return
}
quality, err := strconv.Atoi(r.URL.Query().Get("quality"))
if err != nil {
quality = 90
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "image/jpeg")
img := desktop.GetScreenshotImage()
if err := jpeg.Encode(w, img, &jpeg.Options{Quality: quality}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
// allow downloading and uploading files
if webSocketHandler.FileTransferEnabled() {
router.Get("/file", func(w http.ResponseWriter, r *http.Request) {
password := r.URL.Query().Get("pwd")
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !isAuthorized {
http.Error(w, "bad authorization", http.StatusUnauthorized)
return
}
filename := r.URL.Query().Get("filename")
badChars, _ := regexp.MatchString(`(?m)\.\.(?:\/|$)`, filename)
if filename == "" || badChars {
http.Error(w, "bad filename", http.StatusBadRequest)
return
}
filePath := webSocketHandler.FileTransferPath(filename)
f, err := os.Open(filePath)
if err != nil {
http.Error(w, "not found or unable to open", http.StatusNotFound)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
io.Copy(w, f)
})
router.Post("/file", func(w http.ResponseWriter, r *http.Request) {
password := r.URL.Query().Get("pwd")
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !isAuthorized {
http.Error(w, "bad authorization", http.StatusUnauthorized)
return
}
err = r.ParseMultipartForm(32 << 20)
if err != nil || r.MultipartForm == nil {
logger.Warn().Err(err).Msg("failed to parse multipart form")
http.Error(w, "error parsing form", http.StatusBadRequest)
return
}
for _, formheader := range r.MultipartForm.File["files"] {
filePath := webSocketHandler.FileTransferPath(formheader.Filename)
formfile, err := formheader.Open()
if err != nil {
logger.Warn().Err(err).Msg("failed to open formdata file")
http.Error(w, "error writing file", http.StatusInternalServerError)
return
}
defer formfile.Close()
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
http.Error(w, "unable to open file for writing", http.StatusInternalServerError)
return
}
defer f.Close()
io.Copy(f, formfile)
}
err = r.MultipartForm.RemoveAll()
if err != nil {
logger.Warn().Err(err).Msg("failed to remove multipart form")
}
})
}
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("true"))
})
fs := http.FileServer(http.Dir(conf.Static))
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
if _, err := os.Stat(conf.Static + r.URL.Path); !os.IsNotExist(err) {
fs.ServeHTTP(w, r)
} else {
http.NotFound(w, r)
}
})
server := &http.Server{
Addr: conf.Bind,
Handler: router,
}
return &Server{
logger: logger,
router: router,
http: server,
conf: conf,
}
}
func (s *Server) Start() {
if s.conf.Cert != "" && s.conf.Key != "" {
go func() {
if err := s.http.ListenAndServeTLS(s.conf.Cert, s.conf.Key); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start https server")
}
}()
s.logger.Info().Msgf("https listening on %s", s.http.Addr)
} else {
go func() {
if err := s.http.ListenAndServe(); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start http server")
}
}()
s.logger.Warn().Msgf("http listening on %s", s.http.Addr)
}
}
func (s *Server) Shutdown() error {
return s.http.Shutdown(context.Background())
}

View file

@ -0,0 +1,357 @@
package legacy
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"m1k1o/neko/internal/api"
"m1k1o/neko/internal/api/room"
oldEvent "m1k1o/neko/internal/http/legacy/event"
oldMessage "m1k1o/neko/internal/http/legacy/message"
oldTypes "m1k1o/neko/internal/http/legacy/types"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/types/event"
"m1k1o/neko/pkg/types/message"
"m1k1o/neko/pkg/utils"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var (
// DefaultUpgrader specifies the parameters for upgrading an HTTP
// connection to a WebSocket connection.
DefaultUpgrader = &websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// DefaultDialer is a dialer with all fields set to the default zero values.
DefaultDialer = websocket.DefaultDialer
)
type LegacyHandler struct {
logger zerolog.Logger
serverAddr string
startedAt time.Time
}
func New() *LegacyHandler {
// Init
return &LegacyHandler{
logger: log.With().Str("module", "legacy").Logger(),
serverAddr: "127.0.0.1:8080",
startedAt: time.Now(),
}
}
func (h *LegacyHandler) Route(r types.Router) {
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) error {
s := newSession(h.logger, h.serverAddr)
// create a new websocket connection
connClient, err := DefaultUpgrader.Upgrade(w, r, nil)
if err != nil {
return utils.HttpError(http.StatusInternalServerError).
WithInternalErr(err).
Msg("couldn't upgrade connection to websocket")
}
defer connClient.Close()
s.connClient = connClient
// create a new session
username := r.URL.Query().Get("username")
password := r.URL.Query().Get("password")
err = s.create(username, password)
if err != nil {
h.logger.Error().Err(err).Msg("couldn't create a new session")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Title: "couldn't create a new session",
Message: err.Error(),
})
// we can't return HTTP error here because the connection is already upgraded
return nil
}
defer s.destroy()
// dial to the remote backend
connBackend, _, err := DefaultDialer.Dial("ws://"+h.serverAddr+"/api/ws?token="+url.QueryEscape(s.token), nil)
if err != nil {
h.logger.Error().Err(err).Msg("couldn't dial to the remote backend")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Title: "couldn't dial to the remote backend",
Message: err.Error(),
})
// we can't return HTTP error here because the connection is already upgraded
return nil
}
defer connBackend.Close()
s.connBackend = connBackend
// request signal
if err = s.toBackend(event.SIGNAL_REQUEST, message.SignalRequest{}); err != nil {
h.logger.Error().Err(err).Msg("couldn't request signal")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Title: "couldn't request signal",
Message: err.Error(),
})
// we can't return HTTP error here because the connection is already upgraded
return nil
}
// copy messages between the client and the backend
errClient := make(chan error, 1)
errBackend := make(chan error, 1)
replicateWebsocketConn := func(dst, src *websocket.Conn, errc chan error, rewriteTextMessage func([]byte) error) {
for {
msgType, msg, err := src.ReadMessage()
if err != nil {
m := websocket.FormatCloseMessage(websocket.CloseNormalClosure, fmt.Sprintf("%v", err))
if e, ok := err.(*websocket.CloseError); ok {
if e.Code != websocket.CloseNoStatusReceived {
m = websocket.FormatCloseMessage(e.Code, e.Text)
}
}
errc <- err
dst.WriteMessage(websocket.CloseMessage, m)
break
}
if msgType == websocket.TextMessage {
err = rewriteTextMessage(msg)
if err == nil {
continue
}
if errors.Is(err, ErrBackendRespone) {
h.logger.Error().Err(err).Msg("backend response error")
s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_ERROR,
Title: "backend response error",
Message: err.Error(),
})
continue
} else if errors.Is(err, ErrWebsocketSend) {
errc <- err
break
} else {
h.logger.Error().Err(err).Msg("couldn't rewrite text message")
}
}
}
}
// backend -> client
go replicateWebsocketConn(connClient, connBackend, errClient, s.wsToClient)
// client -> backend
go replicateWebsocketConn(connBackend, connClient, errBackend, s.wsToBackend)
var message string
select {
case err = <-errClient:
message = "websocketproxy: Error when copying from backend to client: %v"
case err = <-errBackend:
message = "websocketproxy: Error when copying from client to backend: %v"
}
if e, ok := err.(*websocket.CloseError); !ok || e.Code == websocket.CloseAbnormalClosure {
h.logger.Error().Err(err).Msg(message)
}
return nil
})
r.Get("/stats", func(w http.ResponseWriter, r *http.Request) error {
s := newSession(h.logger, h.serverAddr)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
if !s.isAdmin {
return utils.HttpUnauthorized().Msg("bad authorization")
}
w.Header().Set("Content-Type", "application/json")
// get all sessions
sessions := []api.SessionDataPayload{}
err = s.apiReq(http.MethodGet, "/api/sessions", nil, &sessions)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// get current control status
control := room.ControlStatusPayload{}
err = s.apiReq(http.MethodGet, "/api/room/control", nil, &control)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// get settings
settings := types.Settings{}
err = s.apiReq(http.MethodGet, "/api/room/settings", nil, &settings)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
var stats oldTypes.Stats
// create empty array so that it's not null in json
stats.Members = []*oldTypes.Member{}
for _, session := range sessions {
if session.State.IsConnected {
stats.Connections++
member, err := profileToMember(session.ID, session.Profile)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// append members
stats.Members = append(stats.Members, member)
} else if session.State.NotConnectedSince != nil {
//
// TODO: This wont work if the user is removed after the session is closed
//
// populate last admin left time
if session.Profile.IsAdmin && (stats.LastAdminLeftAt == nil || (*session.State.NotConnectedSince).After(*stats.LastAdminLeftAt)) {
stats.LastAdminLeftAt = session.State.NotConnectedSince
}
// populate last user left time
if !session.Profile.IsAdmin && (stats.LastUserLeftAt == nil || (*session.State.NotConnectedSince).After(*stats.LastUserLeftAt)) {
stats.LastUserLeftAt = session.State.NotConnectedSince
}
}
}
locks, err := s.settingsToLocks(settings)
if err != nil {
return err
}
stats.Host = control.HostId
// TODO: stats.Banned, not implemented yet
stats.Locked = locks
stats.ServerStartedAt = h.startedAt
stats.ControlProtection = settings.ControlProtection
stats.ImplicitControl = settings.ImplicitHosting
return json.NewEncoder(w).Encode(stats)
})
r.Get("/screenshot.jpg", func(w http.ResponseWriter, r *http.Request) error {
s := newSession(h.logger, h.serverAddr)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
if !s.isAdmin {
return utils.HttpUnauthorized().Msg("bad authorization")
}
quality := r.URL.Query().Get("quality")
// get the screenshot
body, headers, err := s.req(http.MethodGet, "/api/room/screen/shot.jpg?quality="+url.QueryEscape(quality), nil, nil)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// copy headers
w.Header().Set("Content-Length", headers.Get("Content-Length"))
w.Header().Set("Content-Type", headers.Get("Content-Type"))
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
// allow downloading and uploading files
r.Get("/file", func(w http.ResponseWriter, r *http.Request) error {
s := newSession(h.logger, h.serverAddr)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
filename := r.URL.Query().Get("filename")
body, headers, err := s.req(http.MethodGet, "/api/filetransfer?filename="+url.QueryEscape(filename), r.Header, nil)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// copy headers
w.Header().Set("Content-Length", headers.Get("Content-Length"))
w.Header().Set("Content-Type", headers.Get("Content-Type"))
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
r.Post("/file", func(w http.ResponseWriter, r *http.Request) error {
s := newSession(h.logger, h.serverAddr)
// create a new session
username := r.URL.Query().Get("usr")
password := r.URL.Query().Get("pwd")
err := s.create(username, password)
if err != nil {
return utils.HttpForbidden(err.Error())
}
defer s.destroy()
body, _, err := s.req(http.MethodPost, "/api/filetransfer", r.Header, r.Body)
if err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
r.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
_, err := w.Write([]byte("true"))
return err
})
}

View file

@ -1,7 +1,7 @@
package message
import (
"m1k1o/neko/internal/types"
"m1k1o/neko/internal/http/legacy/types"
"github.com/pion/webrtc/v3"
)

View file

@ -0,0 +1,195 @@
package legacy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
oldTypes "m1k1o/neko/internal/http/legacy/types"
"m1k1o/neko/internal/api"
"m1k1o/neko/pkg/types"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
)
var (
ErrWebsocketSend = fmt.Errorf("failed to send message to websocket")
ErrBackendRespone = fmt.Errorf("error response from backend")
)
type memberStruct struct {
member *oldTypes.Member
connected bool
sent bool
}
type session struct {
logger zerolog.Logger
serverAddr string
id string
token string
name string
isAdmin bool
client *http.Client
lastHostID string
lockedControls bool
lockedLogins bool
lockedFileTransfer bool
sessions map[string]*memberStruct
connClient *websocket.Conn
connBackend *websocket.Conn
}
func newSession(logger zerolog.Logger, serverAddr string) *session {
return &session{
logger: logger,
serverAddr: serverAddr,
client: http.DefaultClient,
sessions: make(map[string]*memberStruct),
}
}
func (s *session) req(method, path string, headers http.Header, request io.Reader) (io.ReadCloser, http.Header, error) {
req, err := http.NewRequest(method, "http://"+s.serverAddr+path, request)
if err != nil {
return nil, nil, err
}
for k, v := range headers {
req.Header[k] = v
}
if s.token != "" {
req.Header.Set("Authorization", "Bearer "+s.token)
}
res, err := s.client.Do(req)
if err != nil {
return nil, nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
// try to unmarsal as json error message
var apiErr struct {
Message string `json:"message"`
}
if err := json.Unmarshal(body, &apiErr); err == nil {
return nil, nil, fmt.Errorf("%w: %s", ErrBackendRespone, apiErr.Message)
}
// return raw body if failed to unmarshal
return nil, nil, fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, strings.TrimSpace(string(body)))
}
return res.Body, res.Header, nil
}
func (s *session) apiReq(method, path string, request, response any) error {
reqBody, err := json.Marshal(request)
if err != nil {
return err
}
headers := http.Header{
"Content-Type": []string{"application/json"},
}
resBody, _, err := s.req(method, path, headers, bytes.NewReader(reqBody))
if err != nil {
return err
}
defer resBody.Close()
if resBody == nil {
return nil
}
if response == nil {
io.Copy(io.Discard, resBody)
return nil
}
return json.NewDecoder(resBody).Decode(response)
}
// send message to client (in old format)
func (s *session) toClient(payload any) error {
msg, err := json.Marshal(payload)
if err != nil {
return err
}
err = s.connClient.WriteMessage(websocket.TextMessage, msg)
if err != nil {
return fmt.Errorf("%w: %s", ErrWebsocketSend, err)
}
return nil
}
// send message to backend (in new format)
func (s *session) toBackend(event string, payload any) error {
rawPayload, err := json.Marshal(payload)
if err != nil {
return err
}
msg, err := json.Marshal(&types.WebSocketMessage{
Event: event,
Payload: rawPayload,
})
if err != nil {
return err
}
err = s.connBackend.WriteMessage(websocket.TextMessage, msg)
if err != nil {
return fmt.Errorf("%w: %s", ErrWebsocketSend, err)
}
return nil
}
func (s *session) create(username, password string) error {
data := api.SessionDataPayload{}
err := s.apiReq(http.MethodPost, "/api/login", api.SessionLoginPayload{
Username: username,
Password: password,
}, &data)
if err != nil {
return err
}
s.id = data.ID
s.token = data.Token
s.name = data.Profile.Name
s.isAdmin = data.Profile.IsAdmin
// if Cookie auth, the token will be empty
if s.token == "" {
return fmt.Errorf("token not found - make sure you are not using Cookie auth on the server")
}
return nil
}
func (s *session) destroy() {
defer s.client.CloseIdleConnections()
// logout session
err := s.apiReq(http.MethodPost, "/api/logout", nil, nil)
if err != nil {
s.logger.Error().Err(err).Msg("failed to logout")
}
}

View file

@ -1,9 +1,6 @@
package types
import (
"net/http"
"time"
)
import "time"
type Stats struct {
Connections uint32 `json:"connections"`
@ -21,24 +18,11 @@ type Stats struct {
ImplicitControl bool `json:"implicit_control"`
}
type WebSocket interface {
Address() string
Send(v interface{}) error
Destroy() error
}
type WebSocketHandler interface {
Start()
Shutdown() error
Upgrade(w http.ResponseWriter, r *http.Request) error
Stats() Stats
IsLocked(resource string) bool
IsAdmin(password string) (bool, error)
// File Transfer
CanTransferFiles(password string) (bool, error)
FileTransferPath(filename string) string
FileTransferEnabled() bool
type Member struct {
ID string `json:"id"`
Name string `json:"displayname"`
Admin bool `json:"admin"`
Muted bool `json:"muted"`
}
type FileListItem struct {
@ -46,3 +30,9 @@ type FileListItem struct {
Type string `json:"type"`
Size int64 `json:"size"`
}
type ScreenConfiguration struct {
Width int `json:"width"`
Height int `json:"height"`
Rates map[int]int16 `json:"rates"`
}

View file

@ -0,0 +1,362 @@
package legacy
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v3"
oldEvent "m1k1o/neko/internal/http/legacy/event"
oldMessage "m1k1o/neko/internal/http/legacy/message"
"m1k1o/neko/internal/api/room"
"m1k1o/neko/internal/plugins/chat"
"m1k1o/neko/internal/plugins/filetransfer"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/types/event"
"m1k1o/neko/pkg/types/message"
)
func (s *session) wsToBackend(msg []byte) error {
header := oldMessage.Message{}
err := json.Unmarshal(msg, &header)
if err != nil {
return err
}
switch header.Event {
// Signal Events
case oldEvent.SIGNAL_OFFER:
request := &oldMessage.SignalOffer{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(event.SIGNAL_OFFER, &message.SignalDescription{
SDP: request.SDP,
})
case oldEvent.SIGNAL_ANSWER:
request := &oldMessage.SignalAnswer{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
if request.DisplayName != "" {
s.name = request.DisplayName
err = s.apiReq(http.MethodPost, "/api/profile", map[string]any{
"name": request.DisplayName,
}, nil)
if err != nil {
return err
}
}
return s.toBackend(event.SIGNAL_ANSWER, &message.SignalDescription{
SDP: request.SDP,
})
case oldEvent.SIGNAL_CANDIDATE:
request := &oldMessage.SignalCandidate{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
var candidate webrtc.ICECandidateInit
err = json.Unmarshal([]byte(request.Data), &candidate)
if err != nil {
return err
}
return s.toBackend(event.SIGNAL_CANDIDATE, &message.SignalCandidate{
ICECandidateInit: candidate,
})
// Control Events
case oldEvent.CONTROL_RELEASE:
return s.toBackend(event.CONTROL_RELEASE, nil)
case oldEvent.CONTROL_REQUEST:
return s.toBackend(event.CONTROL_REQUEST, nil)
case oldEvent.CONTROL_GIVE:
request := &oldMessage.Control{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/room/control/give/"+request.ID, nil, nil)
case oldEvent.CONTROL_CLIPBOARD:
request := &oldMessage.Clipboard{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(event.CLIPBOARD_SET, &message.ClipboardData{
Text: request.Text,
})
case oldEvent.CONTROL_KEYBOARD:
request := &oldMessage.Keyboard{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
if request.Layout != nil {
err = s.toBackend(event.KEYBOARD_MAP, &message.KeyboardMap{
KeyboardMap: types.KeyboardMap{
Layout: *request.Layout,
},
})
if err != nil {
return err
}
}
if request.CapsLock != nil || request.NumLock != nil || request.ScrollLock != nil {
err = s.toBackend(event.KEYBOARD_MODIFIERS, &message.KeyboardModifiers{
KeyboardModifiers: types.KeyboardModifiers{
CapsLock: request.CapsLock,
NumLock: request.NumLock,
// ScrollLock: request.ScrollLock, // ScrollLock is deprecated.
},
})
if err != nil {
return err
}
}
return nil
// Chat Events
case oldEvent.CHAT_MESSAGE:
request := &oldMessage.ChatReceive{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(chat.CHAT_MESSAGE, &chat.Content{
Text: request.Content,
})
case oldEvent.CHAT_EMOTE:
request := &oldMessage.EmoteReceive{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
// loopback emote
msg, err := json.Marshal(&oldMessage.EmoteSend{
Event: oldEvent.CHAT_EMOTE,
ID: s.id,
Emote: request.Emote,
})
if err != nil {
return err
}
// loopback emote
err = s.connClient.WriteMessage(websocket.TextMessage, msg)
if err != nil {
return err
}
// broadcast emote to other users
return s.toBackend(event.SEND_BROADCAST, &message.SendBroadcast{
Sender: s.id,
Subject: "emote",
Body: request.Emote,
})
// File Transfer Events
case oldEvent.FILETRANSFER_REFRESH:
return s.toBackend(filetransfer.FILETRANSFER_UPDATE, nil)
// Screen Events
case oldEvent.SCREEN_RESOLUTION:
response := &types.ScreenSize{}
err := s.apiReq(http.MethodGet, "/api/room/screen", nil, response)
if err != nil {
return err
}
return s.toClient(&oldMessage.ScreenResolution{
Event: oldEvent.SCREEN_RESOLUTION,
Width: response.Width,
Height: response.Height,
Rate: response.Rate,
})
case oldEvent.SCREEN_CONFIGURATIONS:
response := &[]types.ScreenSize{}
err := s.apiReq(http.MethodGet, "/api/room/screen/configurations", nil, response)
if err != nil {
return err
}
return s.toClient(&oldMessage.ScreenConfigurations{
Event: oldEvent.SCREEN_CONFIGURATIONS,
Configurations: screenConfigurations(*response),
})
case oldEvent.SCREEN_SET:
request := &oldMessage.ScreenResolution{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.toBackend(event.SCREEN_SET, &message.ScreenSize{
ScreenSize: types.ScreenSize{
Width: request.Width,
Height: request.Height,
Rate: request.Rate,
},
})
// Broadcast Events
case oldEvent.BROADCAST_CREATE:
request := &oldMessage.BroadcastCreate{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/room/broadcast/start", room.BroadcastStatusPayload{
URL: request.URL,
IsActive: true,
}, nil)
case oldEvent.BROADCAST_DESTROY:
return s.apiReq(http.MethodPost, "/api/room/broadcast/stop", nil, nil)
// Admin Events
case oldEvent.ADMIN_LOCK:
request := &oldMessage.AdminLock{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
data := map[string]any{}
switch request.Resource {
case "login":
data["locked_logins"] = true
case "control":
data["locked_controls"] = true
case "file_transfer":
data["plugins"] = map[string]any{
"filetransfer.enabled": false,
}
default:
return fmt.Errorf("unknown resource: %s", request.Resource)
}
return s.apiReq(http.MethodPost, "/api/room/settings", data, nil)
case oldEvent.ADMIN_UNLOCK:
request := &oldMessage.AdminLock{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
data := map[string]any{}
switch request.Resource {
case "login":
data["locked_logins"] = false
case "control":
data["locked_controls"] = false
case "file_transfer":
data["plugins"] = map[string]any{
"filetransfer.enabled": true,
}
default:
return fmt.Errorf("unknown resource: %s", request.Resource)
}
return s.apiReq(http.MethodPost, "/api/room/settings", data, nil)
case oldEvent.ADMIN_CONTROL:
return s.apiReq(http.MethodPost, "/api/room/control/take", nil, nil)
case oldEvent.ADMIN_RELEASE:
return s.apiReq(http.MethodPost, "/api/room/control/reset", nil, nil)
case oldEvent.ADMIN_GIVE:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/room/control/give/"+request.ID, nil, nil)
case oldEvent.ADMIN_BAN:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
// TODO: No WS equivalent, call HTTP API.
return fmt.Errorf("event not implemented: %s", header.Event)
case oldEvent.ADMIN_KICK:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
// TODO: we need to send a message to the user before kicking them
// that they are being kicked so they will not automatically rejoin
return s.apiReq(http.MethodPost, "/api/members/"+request.ID, map[string]any{
"can_login": false,
}, nil)
case oldEvent.ADMIN_MUTE:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/members/"+request.ID, map[string]any{
"plugins": map[string]any{
"chat.can_send": false,
},
}, nil)
case oldEvent.ADMIN_UNMUTE:
request := &oldMessage.Admin{}
err := json.Unmarshal(msg, request)
if err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/members/"+request.ID, map[string]any{
"plugins": map[string]any{
"chat.can_send": true,
},
}, nil)
default:
return fmt.Errorf("unknown event type: %s", header.Event)
}
}

View file

@ -0,0 +1,733 @@
package legacy
import (
"encoding/json"
"errors"
"fmt"
"github.com/pion/webrtc/v3"
oldEvent "m1k1o/neko/internal/http/legacy/event"
oldMessage "m1k1o/neko/internal/http/legacy/message"
oldTypes "m1k1o/neko/internal/http/legacy/types"
"m1k1o/neko/internal/plugins/chat"
"m1k1o/neko/internal/plugins/filetransfer"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/types/event"
"m1k1o/neko/pkg/types/message"
)
func profileToMember(id string, profile types.MemberProfile) (*oldTypes.Member, error) {
settings := chat.Settings{
CanSend: true, // defaults to true
CanReceive: true, // defaults to true
}
err := profile.Plugins.Unmarshal(chat.PluginName, &settings)
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
return nil, fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", chat.PluginName, err)
}
return &oldTypes.Member{
ID: id,
Name: profile.Name,
Admin: profile.IsAdmin,
Muted: !settings.CanSend,
}, nil
}
func screenConfigurations(screenSizes []types.ScreenSize) map[int]oldTypes.ScreenConfiguration {
rates := map[string][]int16{}
for _, size := range screenSizes {
key := fmt.Sprintf("%dx%d", size.Width, size.Height)
rates[key] = append(rates[key], size.Rate)
}
usedScreenSizes := map[string]struct{}{}
screenSizesList := map[int]oldTypes.ScreenConfiguration{}
for i, size := range screenSizes {
key := fmt.Sprintf("%dx%d", size.Width, size.Height)
if _, ok := usedScreenSizes[key]; ok {
continue
}
ratesMap := map[int]int16{}
for i, rate := range rates[key] {
ratesMap[i] = rate
}
screenSizesList[i] = oldTypes.ScreenConfiguration{
Width: size.Width,
Height: size.Height,
Rates: ratesMap,
}
}
return screenSizesList
}
func (s *session) settingsToLocks(settings types.Settings) (map[string]string, error) {
//
// FileTransfer
//
filetransferSettings := filetransfer.Settings{
Enabled: true, // defaults to true
}
err := settings.Plugins.Unmarshal(filetransfer.PluginName, &filetransferSettings)
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
return nil, fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", filetransfer.PluginName, err)
}
//
// Locks
//
locks := map[string]string{}
if settings.LockedLogins {
locks["login"] = "" // TODO: We don't know who locked the login.
s.lockedLogins = true
}
if settings.LockedControls {
locks["control"] = "" // TODO: We don't know who locked the control.
s.lockedControls = true
}
if !filetransferSettings.Enabled {
locks["file_transfer"] = "" // TODO: We don't know who locked the file transfer.
s.lockedFileTransfer = true
}
return locks, nil
}
func (s *session) sendControlHost(request message.ControlHost) error {
lastHostID := s.lastHostID
if request.HasHost {
s.lastHostID = request.ID
if request.ID == request.HostID {
if request.ID == lastHostID || lastHostID == "" {
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_LOCKED,
ID: request.HostID,
})
} else {
return s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_CONTROL,
ID: request.ID,
Target: lastHostID,
})
}
} else {
return s.toClient(&oldMessage.ControlTarget{
Event: oldEvent.CONTROL_GIVE,
ID: request.HostID,
Target: request.ID,
})
}
}
if request.ID != "" {
s.lastHostID = ""
if request.ID == lastHostID {
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_RELEASE,
ID: request.ID,
})
} else {
return s.toClient(&oldMessage.Control{
Event: oldEvent.ADMIN_RELEASE,
ID: request.ID,
})
}
}
return nil
}
func (s *session) wsToClient(msg []byte) error {
data := types.WebSocketMessage{}
err := json.Unmarshal(msg, &data)
if err != nil {
return err
}
switch data.Event {
// System Events
case event.SYSTEM_DISCONNECT:
request := &message.SystemDisconnect{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.SystemMessage{
Event: oldEvent.SYSTEM_DISCONNECT,
Message: request.Message,
})
case event.SYSTEM_INIT:
request := &message.SystemInit{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
//
// MembersList
//
membersList := []*oldTypes.Member{}
s.sessions = map[string]*memberStruct{}
for id, session := range request.Sessions {
if !session.State.IsConnected {
continue
}
member, err := profileToMember(id, session.Profile)
if err != nil {
return err
}
membersList = append(membersList, member)
s.sessions[id] = &memberStruct{
sent: member.Name != "",
connected: true,
member: member,
}
}
err = s.toClient(&oldMessage.MembersList{
Event: oldEvent.MEMBER_LIST,
Members: membersList,
})
if err != nil {
return err
}
//
// ScreenSize
//
err = s.toClient(&oldMessage.ScreenResolution{
Event: oldEvent.SCREEN_RESOLUTION,
Width: request.ScreenSize.Width,
Height: request.ScreenSize.Height,
Rate: request.ScreenSize.Rate,
})
if err != nil {
return err
}
// actually its already set when we create the session
s.id = request.SessionId
//
// ControlHost
//
err = s.sendControlHost(request.ControlHost)
if err != nil {
return err
}
locks, err := s.settingsToLocks(request.Settings)
if err != nil {
return err
}
return s.toClient(&oldMessage.SystemInit{
Event: oldEvent.SYSTEM_INIT,
ImplicitHosting: request.Settings.ImplicitHosting,
Locks: locks,
FileTransfer: true, // TODO: We don't know if file transfer is enabled, we would need to check the global config somehow.
})
case event.SYSTEM_ADMIN:
request := &message.SystemAdmin{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
//
// ScreenSizesList
//
err = s.toClient(&oldMessage.ScreenConfigurations{
Event: oldEvent.SCREEN_CONFIGURATIONS,
Configurations: screenConfigurations(request.ScreenSizesList),
})
if err != nil {
return err
}
//
// BroadcastStatus
//
return s.toClient(&oldMessage.BroadcastStatus{
Event: oldEvent.BROADCAST_STATUS,
URL: request.BroadcastStatus.URL,
IsActive: request.BroadcastStatus.IsActive,
})
// Member Events
case event.SESSION_CREATED:
request := &message.SessionData{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
member, err := profileToMember(request.ID, request.Profile)
if err != nil {
return err
}
// only save session - will be notified on connect
s.sessions[request.ID] = &memberStruct{
member: member,
}
return nil
case event.SESSION_DELETED:
request := &message.SessionID{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
// only continue if session is in the list - should have been already removed
if _, ok := s.sessions[request.ID]; !ok {
return nil
}
delete(s.sessions, request.ID)
return s.toClient(&oldMessage.MemberDisconnected{
Event: oldEvent.MEMBER_DISCONNECTED,
ID: request.ID,
})
case event.SESSION_PROFILE:
request := &message.MemberProfile{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
// session profile is expected to change when updating a name after connecting
m, ok := s.sessions[request.ID]
if !ok || m == nil {
return nil
}
// update member profile
member, err := profileToMember(request.ID, request.MemberProfile)
if err != nil {
return err
}
mutedChanged := m.member.Muted != member.Muted
m.member = member
if m.connected && !m.sent && member.Name != "" {
m.sent = true
// oldEvent.MEMBER_CONNECTED if not sent already
err = s.toClient(&oldMessage.Member{
Event: oldEvent.MEMBER_CONNECTED,
Member: member,
})
if err != nil {
return err
}
}
if mutedChanged && member.Muted {
return s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_MUTE,
ID: "", // TODO: We don't know who (un)muted the user.
Target: request.ID,
})
} else if mutedChanged && !member.Muted {
return s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_UNMUTE,
ID: "", // TODO: We don't know who (un)muted the user.
Target: request.ID,
})
}
return nil
case event.SESSION_STATE:
request := &message.SessionState{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
m, ok := s.sessions[request.ID]
if !ok {
return nil
}
if request.IsConnected {
m.connected = true
if m.member.Muted {
err = s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_MUTE,
ID: "", // TODO: We don't know who (un)muted the user.
Target: request.ID,
})
if err != nil {
return err
}
}
if !m.sent && m.member.Name != "" {
m.sent = true
// oldEvent.MEMBER_CONNECTED if not sent already
return s.toClient(&oldMessage.Member{
Event: oldEvent.MEMBER_CONNECTED,
Member: m.member,
})
}
}
if !request.IsConnected {
delete(s.sessions, request.ID)
// oldEvent.MEMBER_DISCONNECTED if nor sent already
return s.toClient(&oldMessage.MemberDisconnected{
Event: oldEvent.MEMBER_DISCONNECTED,
ID: request.ID,
})
}
return nil
// Signal Events
case event.SIGNAL_OFFER:
request := &message.SignalDescription{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.SignalOffer{
Event: oldEvent.SIGNAL_OFFER,
SDP: request.SDP,
})
case event.SIGNAL_ANSWER:
request := &message.SignalDescription{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.SignalAnswer{
Event: oldEvent.SIGNAL_ANSWER,
DisplayName: s.name,
SDP: request.SDP,
})
case event.SIGNAL_CANDIDATE:
request := &message.SignalCandidate{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
json, err := json.Marshal(request.ICECandidateInit)
if err != nil {
return err
}
return s.toClient(&oldMessage.SignalCandidate{
Event: oldEvent.SIGNAL_CANDIDATE,
Data: string(json),
})
case event.SIGNAL_PROVIDE:
request := &message.SignalProvide{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
iceServers := []webrtc.ICEServer{}
for _, ice := range request.ICEServers {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: ice.URLs,
Username: ice.Username,
Credential: ice.Credential,
CredentialType: webrtc.ICECredentialTypePassword,
})
}
return s.toClient(&oldMessage.SignalProvide{
Event: oldEvent.SIGNAL_PROVIDE,
ID: s.id, // SessionId
SDP: request.SDP,
Lite: len(iceServers) == 0, // if no ICE servers are provided, it's a lite offer
ICE: iceServers,
})
// Control Events
case event.CLIPBOARD_UPDATED:
request := &message.ClipboardData{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.Clipboard{
Event: oldEvent.CONTROL_CLIPBOARD,
Text: request.Text,
})
case event.CONTROL_HOST:
request := &message.ControlHost{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.sendControlHost(*request)
case event.CONTROL_REQUEST:
request := &message.SessionID{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
if s.id == request.ID {
// if i am the one that is requesting, send CONTROL_REQUEST to me
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_REQUEST,
ID: request.ID,
})
} else {
// if not, let me know someone else is requesting
return s.toClient(&oldMessage.Control{
Event: oldEvent.CONTROL_REQUESTING,
ID: request.ID,
})
}
// Chat Events
case chat.CHAT_MESSAGE:
request := &chat.Message{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.ChatSend{
Event: oldEvent.CHAT_MESSAGE,
ID: request.ID,
Content: request.Content.Text,
})
case event.SEND_BROADCAST:
request := &message.SendBroadcast{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
if request.Subject == "emote" {
return s.toClient(&oldMessage.EmoteSend{
Event: oldEvent.CHAT_EMOTE,
ID: request.Sender,
Emote: request.Body.(string),
})
}
return nil
// File Transfer Events
case filetransfer.FILETRANSFER_UPDATE:
request := &filetransfer.Message{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
files := []oldTypes.FileListItem{}
for _, file := range request.Files {
var itemType string
switch file.Type {
case filetransfer.ItemTypeFile:
itemType = "file"
case filetransfer.ItemTypeDir:
itemType = "dir"
}
files = append(files, oldTypes.FileListItem{
Filename: file.Name,
Type: itemType,
Size: file.Size,
})
}
return s.toClient(&oldMessage.FileTransferList{
Event: oldEvent.FILETRANSFER_LIST,
Cwd: request.RootDir,
Files: files,
})
// Screen Events
case event.SCREEN_UPDATED:
request := &message.ScreenSizeUpdate{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.ScreenResolution{
Event: oldEvent.SCREEN_RESOLUTION,
ID: request.ID,
Width: request.ScreenSize.Width,
Height: request.ScreenSize.Height,
Rate: request.ScreenSize.Rate,
})
// Broadcast Events
case event.BROADCAST_STATUS:
request := &message.BroadcastStatus{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
return s.toClient(&oldMessage.BroadcastStatus{
Event: oldEvent.BROADCAST_STATUS,
URL: request.URL,
IsActive: request.IsActive,
})
// Admin Events
case event.SYSTEM_SETTINGS:
request := &message.SystemSettingsUpdate{}
err := json.Unmarshal(data.Payload, request)
if err != nil {
return err
}
if s.lockedControls != request.LockedControls {
s.lockedControls = request.LockedControls
if request.LockedControls {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_LOCK,
Resource: "control",
ID: request.ID,
})
} else {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_UNLOCK,
Resource: "control",
ID: request.ID,
})
}
if err != nil {
return err
}
}
if s.lockedLogins != request.LockedLogins {
s.lockedLogins = request.LockedLogins
if request.LockedLogins {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_LOCK,
Resource: "login",
ID: request.ID,
})
} else {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_UNLOCK,
Resource: "login",
ID: request.ID,
})
}
if err != nil {
return err
}
}
//
// FileTransfer
//
filetransferSettings := filetransfer.Settings{
Enabled: true, // defaults to true
}
err = request.Settings.Plugins.Unmarshal(filetransfer.PluginName, &filetransferSettings)
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
return fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", filetransfer.PluginName, err)
}
if s.lockedFileTransfer != !filetransferSettings.Enabled {
s.lockedFileTransfer = !filetransferSettings.Enabled
if !filetransferSettings.Enabled {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_LOCK,
Resource: "file_transfer",
ID: request.ID,
})
} else {
err = s.toClient(&oldMessage.AdminLock{
Event: oldEvent.ADMIN_UNLOCK,
Resource: "file_transfer",
ID: request.ID,
})
}
if err != nil {
return err
}
}
return nil
/*
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_BAN,
})
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_KICK,
})
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_MUTE,
})
case:
s.toClient(&oldMessage.AdminTarget{
Event: oldEvent.ADMIN_UNMUTE,
})
*/
case event.SYSTEM_HEARTBEAT:
return nil
default:
return fmt.Errorf("unknown event type: %s", data.Event)
}
}

View file

@ -5,16 +5,24 @@ import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/chi/middleware"
"github.com/rs/zerolog"
"m1k1o/neko/pkg/types"
"m1k1o/neko/pkg/utils"
)
type logformatter struct {
type logFormatter struct {
logger zerolog.Logger
}
func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry {
req := map[string]interface{}{}
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
@ -32,43 +40,96 @@ func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry {
req["agent"] = r.UserAgent()
req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
fields := map[string]interface{}{}
fields["req"] = req
return &logentry{
fields: fields,
logger: l.logger,
return &logEntry{
logger: l.logger.With().Interface("req", req).Logger(),
}
}
type logentry struct {
logger zerolog.Logger
fields map[string]interface{}
errors []map[string]interface{}
type logEntry struct {
logger zerolog.Logger
err error
panic *logPanic
session types.Session
}
func (e *logentry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) {
res := map[string]interface{}{}
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
e.fields["res"] = res
e.fields["module"] = "http"
logger := e.logger.With().Interface("res", res).Logger()
if len(e.errors) > 0 {
e.fields["errors"] = e.errors
e.logger.Error().Fields(e.fields).Msgf("request failed (%d)", status)
} else {
e.logger.Debug().Fields(e.fields).Msgf("request complete (%d)", status)
// 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)
}
func (e *logentry) Panic(v interface{}, stack []byte) {
err := map[string]interface{}{}
err["message"] = fmt.Sprintf("%+v", v)
err["stack"] = string(stack)
type nulllog struct{}
e.errors = append(e.errors, err)
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) {
}

View file

@ -0,0 +1,136 @@
package http
import (
"context"
"net/http"
"os"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/http/legacy"
"m1k1o/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"))
}))
// Legacy handler
legacy.New().Route(router)
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())
}

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"
"m1k1o/neko/pkg/auth"
"m1k1o/neko/pkg/types"
"m1k1o/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"
"m1k1o/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"
"m1k1o/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 (
"m1k1o/neko/pkg/types"
)
type MemberEntry struct {
Password string `json:"password"`
Profile types.MemberProfile `json:"profile"`
}
type Config struct {
Path string
Hash bool
}

View file

@ -0,0 +1,168 @@
package member
import (
"errors"
"sync"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko/internal/config"
"m1k1o/neko/internal/member/file"
"m1k1o/neko/internal/member/multiuser"
"m1k1o/neko/internal/member/noauth"
"m1k1o/neko/internal/member/object"
"m1k1o/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
}
if !profile.IsAdmin && manager.sessions.Settings().LockedLogins {
return nil, "", types.ErrSessionLoginsLocked
}
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"
"m1k1o/neko/pkg/types"
"m1k1o/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 "m1k1o/neko/pkg/types"
type Config struct {
AdminPassword string
UserPassword string
AdminProfile types.MemberProfile
UserProfile types.MemberProfile
}

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