From 0c8af21fab2b908f79efb38fe866db706f4f4bc8 Mon Sep 17 00:00:00 2001 From: Craig Date: Mon, 13 Jan 2020 08:05:38 +0000 Subject: [PATCH] first commit --- .devcontainer/Dockerfile | 129 +++ .devcontainer/devcontainer.json | 25 + .devcontainer/docker-compose.yaml | 16 + .docker/build.sh | 10 + .docker/entrypoint.sh | 7 + .docker/openbox.xml | 760 ++++++++++++++++ .docker/policies.json | 25 + .docker/pulseaudio.pa | 4 + .docker/supervisord.conf | 32 + .docker/supervisord.dev.conf | 28 + .docker/test.sh | 46 + .docker/x11vnc.sh | 7 + .gitattributes | 22 + .github/.gitkeep | 0 .gitignore | 38 + Dockerfile | 69 ++ README.md | 17 + client/.browserslistrc | 2 + client/.editorconfig | 9 + client/.eslintrc | 16 + client/.prettierrc | 8 + client/.vscode/settings.json | 25 + client/README.md | 0 client/package.json | 34 + client/public/android-chrome-192x192.png | Bin 0 -> 5433 bytes client/public/android-chrome-512x512.png | Bin 0 -> 16860 bytes client/public/apple-touch-icon.png | Bin 0 -> 4080 bytes client/public/browserconfig.xml | 9 + client/public/favicon-16x16.png | Bin 0 -> 662 bytes client/public/favicon-32x32.png | Bin 0 -> 1003 bytes client/public/index.html | 23 + client/public/mstile-144x144.png | Bin 0 -> 3817 bytes client/public/mstile-150x150.png | Bin 0 -> 3813 bytes client/public/mstile-310x150.png | Bin 0 -> 4113 bytes client/public/mstile-310x310.png | Bin 0 -> 8647 bytes client/public/mstile-70x70.png | Bin 0 -> 2646 bytes client/public/safari-pinned-tab.svg | 73 ++ client/public/site.webmanifest | 19 + client/src/App.vue | 847 ++++++++++++++++++ client/src/assets/logo.svg | 40 + client/src/assets/styles/_reset.scss | 358 ++++++++ client/src/assets/styles/_variables.scss | 10 + client/src/assets/styles/main.scss | 23 + .../assets/styles/vendor/_font-awesome.scss | 20 + client/src/main.ts | 12 + client/src/types/shims-scss.d.ts | 1 + client/src/types/shims-tsx.d.ts | 13 + client/src/types/shims-vue.d.ts | 4 + client/tsconfig.json | 37 + client/vue.config.js | 11 + neko.code-workspace | 47 + server/.editorconfig | 9 + server/.vscode/launch.json | 16 + server/.vscode/settings.json | 22 + server/Makefile | 9 + server/README.md | 0 server/cmd/neko/main.go | 18 + server/cmd/root.go | 35 + server/cmd/serve.go | 37 + server/go.mod | 15 + server/go.sum | 300 +++++++ server/internal/config/config.go | 8 + server/internal/config/root.go | 37 + server/internal/config/serve.go | 52 ++ server/internal/gst/gst.c | 88 ++ server/internal/gst/gst.go | 125 +++ server/internal/gst/gst.h | 16 + server/internal/http/api.go | 14 + server/internal/http/endpoint/endpoint.go | 102 +++ server/internal/http/endpoint/error.go | 17 + server/internal/http/handler/handler.go | 55 ++ server/internal/http/handler/ping.go | 10 + server/internal/http/handler/websocket.go | 9 + server/internal/http/middleware/logger.go | 80 ++ server/internal/http/middleware/middleware.go | 12 + server/internal/http/middleware/recover.go | 24 + server/internal/http/middleware/request.go | 89 ++ server/internal/http/response/response.go | 32 + server/internal/keys/keyboard.go | 203 +++++ server/internal/keys/mouse.go | 21 + server/internal/nanoid/nanoid.go | 71 ++ server/internal/preflight/config.go | 48 + server/internal/preflight/logs.go | 60 ++ server/internal/structs/version.go | 21 + server/internal/utils/color.go | 34 + server/internal/utils/header.go | 10 + server/internal/utils/map.go | 25 + server/internal/webrtc/data.go | 22 + server/internal/webrtc/manager.go | 73 ++ server/internal/webrtc/messages.go | 15 + server/internal/webrtc/peer.go | 220 +++++ server/internal/webrtc/session.go | 41 + server/internal/webrtc/websocket.go | 222 +++++ server/neko.go | 116 +++ tsconfig.json | 3 + 95 files changed, 5312 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yaml create mode 100644 .docker/build.sh create mode 100644 .docker/entrypoint.sh create mode 100644 .docker/openbox.xml create mode 100644 .docker/policies.json create mode 100644 .docker/pulseaudio.pa create mode 100644 .docker/supervisord.conf create mode 100644 .docker/supervisord.dev.conf create mode 100755 .docker/test.sh create mode 100755 .docker/x11vnc.sh create mode 100644 .gitattributes create mode 100644 .github/.gitkeep create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/.browserslistrc create mode 100644 client/.editorconfig create mode 100644 client/.eslintrc create mode 100644 client/.prettierrc create mode 100644 client/.vscode/settings.json create mode 100644 client/README.md create mode 100644 client/package.json create mode 100644 client/public/android-chrome-192x192.png create mode 100644 client/public/android-chrome-512x512.png create mode 100644 client/public/apple-touch-icon.png create mode 100644 client/public/browserconfig.xml create mode 100644 client/public/favicon-16x16.png create mode 100644 client/public/favicon-32x32.png create mode 100644 client/public/index.html create mode 100644 client/public/mstile-144x144.png create mode 100644 client/public/mstile-150x150.png create mode 100644 client/public/mstile-310x150.png create mode 100644 client/public/mstile-310x310.png create mode 100644 client/public/mstile-70x70.png create mode 100644 client/public/safari-pinned-tab.svg create mode 100644 client/public/site.webmanifest create mode 100644 client/src/App.vue create mode 100644 client/src/assets/logo.svg create mode 100644 client/src/assets/styles/_reset.scss create mode 100644 client/src/assets/styles/_variables.scss create mode 100644 client/src/assets/styles/main.scss create mode 100644 client/src/assets/styles/vendor/_font-awesome.scss create mode 100644 client/src/main.ts create mode 100644 client/src/types/shims-scss.d.ts create mode 100644 client/src/types/shims-tsx.d.ts create mode 100644 client/src/types/shims-vue.d.ts create mode 100644 client/tsconfig.json create mode 100644 client/vue.config.js create mode 100644 neko.code-workspace create mode 100644 server/.editorconfig create mode 100644 server/.vscode/launch.json create mode 100644 server/.vscode/settings.json create mode 100644 server/Makefile create mode 100644 server/README.md create mode 100644 server/cmd/neko/main.go create mode 100644 server/cmd/root.go create mode 100644 server/cmd/serve.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/internal/config/config.go create mode 100644 server/internal/config/root.go create mode 100644 server/internal/config/serve.go create mode 100644 server/internal/gst/gst.c create mode 100644 server/internal/gst/gst.go create mode 100644 server/internal/gst/gst.h create mode 100644 server/internal/http/api.go create mode 100644 server/internal/http/endpoint/endpoint.go create mode 100644 server/internal/http/endpoint/error.go create mode 100644 server/internal/http/handler/handler.go create mode 100644 server/internal/http/handler/ping.go create mode 100644 server/internal/http/handler/websocket.go create mode 100644 server/internal/http/middleware/logger.go create mode 100644 server/internal/http/middleware/middleware.go create mode 100644 server/internal/http/middleware/recover.go create mode 100644 server/internal/http/middleware/request.go create mode 100644 server/internal/http/response/response.go create mode 100644 server/internal/keys/keyboard.go create mode 100644 server/internal/keys/mouse.go create mode 100644 server/internal/nanoid/nanoid.go create mode 100644 server/internal/preflight/config.go create mode 100644 server/internal/preflight/logs.go create mode 100644 server/internal/structs/version.go create mode 100644 server/internal/utils/color.go create mode 100644 server/internal/utils/header.go create mode 100644 server/internal/utils/map.go create mode 100644 server/internal/webrtc/data.go create mode 100644 server/internal/webrtc/manager.go create mode 100644 server/internal/webrtc/messages.go create mode 100644 server/internal/webrtc/peer.go create mode 100644 server/internal/webrtc/session.go create mode 100644 server/internal/webrtc/websocket.go create mode 100644 server/neko.go create mode 100644 tsconfig.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..463bdd1 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,129 @@ +FROM golang:stretch + +# This Dockerfile adds a non-root user with sudo access. Use the "remoteUser" +# property in devcontainer.json to use it. On Linux, the container user's GID/UIDs +# will be updated to match your local UID/GID (when using the dockerFile property). +# See https://aka.ms/vscode-remote/containers/non-root-user for details. +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# Avoid warnings by switching to noninteractive +ENV DEBIAN_FRONTEND=noninteractive + +# Runtime for testing and compileing +RUN apt-get update \ + && apt-get -y install supervisor openbox dbus-x11 ttf-freefont xvfb pulseaudio consolekit firefox-esr x11vnc \ + && apt-get -y install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good gstreamer1.0-pulseaudio \ + && apt-get -y install gcc libc6-dev \ + && apt-get -y install libx11-dev xorg-dev libxtst-dev libpng++-dev \ + && apt-get -y install xcb libxcb-xkb-dev x11-xkb-utils libx11-xcb-dev libxkbcommon-x11-dev \ + && apt-get -y install libxkbcommon-dev \ + && apt-get -y install xsel xclip + +# Configure apt, install packages and tools +RUN apt-get update \ + && apt-get -y install --no-install-recommends apt-utils apt-transport-https dialog 2>&1 \ + # + # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed + && apt-get -y install git iproute2 procps lsb-release xz-utils \ + # + # Install Docker CE CLI + && apt-get install -y apt-transport-https ca-certificates curl gnupg-agent software-properties-common lsb-release \ + && curl -fsSL https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/gpg | (OUT=$(apt-key add - 2>&1) || echo $OUT) \ + && add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]') $(lsb_release -cs) stable" \ + && apt-get update \ + && apt-get install -y docker-ce-cli \ + # + # Install Docker Compose + && curl -sSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose \ + && chmod +x /usr/local/bin/docker-compose \ + # + # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/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 for the non-root user + && apt-get install -y sudo \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +ENV NODE_VERSION 12.14.1 + +RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \ + && case "${dpkgArch##*-}" in \ + amd64) ARCH='x64';; \ + ppc64el) ARCH='ppc64le';; \ + s390x) ARCH='s390x';; \ + arm64) ARCH='arm64';; \ + armhf) ARCH='armv7l';; \ + i386) ARCH='x86';; \ + *) echo "unsupported architecture"; exit 1 ;; \ + esac \ + # gpg keys listed at https://github.com/nodejs/node#release-keys + && set -ex \ + && for key in \ + 94AE36675C464D64BAFA68DD7434390BDBE9B9C5 \ + FD3A5288F042B6850C66B31F09FE44734EB7990E \ + 71DCFD284A79C3B38668286BC97EC7A07EDE3FC1 \ + DD8F2338BAE7501E3DD5AC78C273792F7D83545D \ + C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \ + B9AE9905FFD7803F25714661B63B535A4C206CA9 \ + 77984A986EBC2AA786BC0F66B01FBB92821C587A \ + 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \ + 4ED778F539E3634C779C87C6D7062848A1AB005C \ + A48C2BEE680E841632CD4E44F07496B3EB3C1762 \ + B9E2F5981AA6E0CD28160D9FF13993A75599653C \ + ; do \ + gpg --batch --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys "$key" || \ + gpg --batch --keyserver hkp://ipv4.pool.sks-keyservers.net --recv-keys "$key" || \ + gpg --batch --keyserver hkp://pgp.mit.edu:80 --recv-keys "$key" ; \ + done \ + && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" \ + && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \ + && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && grep " node-v$NODE_VERSION-linux-$ARCH.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ + && tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ + && rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ + && ln -s /usr/local/bin/node /usr/local/bin/nodejs + +USER $USERNAME + +# Install packages and tools +RUN go get -x -d github.com/stamblerre/gocode 2>&1 \ + && go build -o gocode-gomod github.com/stamblerre/gocode \ + && mv gocode-gomod $GOPATH/bin/ \ + # + # Install Go tools + && go get -u -v \ + github.com/mdempsky/gocode \ + github.com/uudashr/gopkgs/cmd/gopkgs \ + github.com/ramya-rao-a/go-outline \ + github.com/acroca/go-symbols \ + github.com/godoctor/godoctor \ + golang.org/x/tools/cmd/guru \ + golang.org/x/tools/cmd/gorename \ + github.com/rogpeppe/godef \ + github.com/zmb3/gogetdoc \ + github.com/haya14busa/goplay/cmd/goplay \ + github.com/sqs/goreturns \ + github.com/josharian/impl \ + github.com/davidrjenni/reftools/cmd/fillstruct \ + github.com/fatih/gomodifytags \ + github.com/cweill/gotests/... \ + golang.org/x/tools/cmd/goimports \ + golang.org/x/lint/golint \ + golang.org/x/tools/gopls \ + github.com/alecthomas/gometalinter \ + honnef.co/go/tools/... \ + github.com/golangci/golangci-lint/cmd/golangci-lint \ + github.com/mgechev/revive \ + github.com/derekparker/delve/cmd/dlv 2>&1 + +ENV GO111MODULE=on + +# Switch back to dialog for any ad-hoc use of apt-get +ENV DEBIAN_FRONTEND=dialog + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..0076d05 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +{ + "name": "neko", + "service": "neko", + "dockerComposeFile": "docker-compose.yaml", + "workspaceFolder": "/workspace", + "remoteUser": "vscode", + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "go.gopath": "/go" + }, + "extensions": [ + "ms-vscode.go", + "octref.vetur", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-vscode-remote.vscode-remote-extensionpack", + "ms-vscode-remote.remote-containers", + "ms-azuretools.vscode-docker", + "editorconfig.editorconfig", + "psioniq.psi-header", + "gruntfuggly.todo-tree", + "swyphcosmo.spellchecker", + "eamodio.gitlens" + ] +} diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml new file mode 100644 index 0000000..bd08062 --- /dev/null +++ b/.devcontainer/docker-compose.yaml @@ -0,0 +1,16 @@ +version: '3.6' +services: + neko: + network_mode: host + build: + context: . + dockerfile: Dockerfile + shm_size: '2gb' + cap_add: + - SYS_PTRACE + security_opt: + - seccomp:unconfined + volumes: + - /home/nurd/neko:/workspace + - /var/run/docker.sock:/var/run/docker.sock + command: "/bin/sh -c \"while sleep 1000; do :; done\"" diff --git a/.docker/build.sh b/.docker/build.sh new file mode 100644 index 0000000..79a95f1 --- /dev/null +++ b/.docker/build.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +cd ../server +go get && make build + +cd ../client +npm install && npm run build + +cd ../ +docker build -f Dockerfile -t neko . \ No newline at end of file diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh new file mode 100644 index 0000000..fbd9fdc --- /dev/null +++ b/.docker/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +echo "Starting dbus" +/etc/init.d/dbus start + +echo "Starting supervisord" +su -p -l $NEKO_USER -c '/usr/bin/supervisord -c /etc/neko/supervisord.conf' -s /bin/bash \ No newline at end of file diff --git a/.docker/openbox.xml b/.docker/openbox.xml new file mode 100644 index 0000000..255fe3d --- /dev/null +++ b/.docker/openbox.xml @@ -0,0 +1,760 @@ + + + + + + + + 10 + 20 + + + + + no + + yes + + + + yes + + no + + yes + + no + + 200 + + no + + + + + Smart + +
yes
+ + Primary + + 1 + +
+ + + Clearlooks + NLIMC + + yes + yes + + sans + 8 + + bold + + normal + + + + sans + 8 + + bold + + normal + + + + sans + 9 + + normal + + normal + + + + sans + 9 + + normal + + normal + + + + sans + 9 + + bold + + normal + + + + sans + 9 + + bold + + normal + + + + + + + 4 + 1 + + + + 875 + + + + + yes + Nonpixel + + Center + + + + + 10 + + 10 + + + + + + + 0 + 0 + 0 + 0 + + + + TopLeft + + 0 + 0 + no + Above + + Vertical + + no + 300 + + 300 + + Middle + + + + + C-g + + + + leftno + + + rightno + + + upno + + + downno + + + leftno + + + rightno + + + upno + + + downno + + + 1 + + + 2 + + + 3 + + + 4 + + + + + + + + + + + + + + + + + + + + scrot -s + + + + + + + + + + + + + + + + + + + + + + + + yesyes + + + + + + + + + + + + right + + + + + left + + + + + up + + + + + down + + + + + + + + true + Konqueror + + kfmclient openProfile filemanagement + + + + + scrot + + + + + 1 + + 500 + + 400 + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + previous + + + next + + + previous + + + next + + + previous + + + next + + + + + + + + + + + + + + no + + + + + + + + + + + yes + + + + + + + + + + + + + + + + + + + + + + + + + + + top + + + + + + left + + + + + + right + + + + + + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + vertical + + + horizontal + + + + + + + + + + + + + + + + + previous + + + next + + + + previous + + + next + + + previous + + + next + + + + + + + + + + + + + + + + + + + + previous + + + next + + + previous + + + next + + + + + + + + + + /var/lib/openbox/debian-menu.xml + menu.xml + 200 + + no + + 100 + + 400 + + yes + + yes + + + + + + + +
diff --git a/.docker/policies.json b/.docker/policies.json new file mode 100644 index 0000000..5cf126a --- /dev/null +++ b/.docker/policies.json @@ -0,0 +1,25 @@ +{ + "policies": { + "DisableAppUpdate": true, + "DisableTelemetry": true, + "DontCheckDefaultBrowser": true, + "BlockAboutConfig": true, + "OverrideFirstRunPage": "", + "OfferToSaveLogins": false, + "PromptForDownloadLocation":false, + "ExtensionSettings": { + "uBlock0@raymondhill.net": { + "installation_mode": "force_installed", + "install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi" + } + }, + "WebsiteFilter": { + "Block": [], + "Exceptions": [] + }, + "Homepage": { + "Additional": [], + "StartPage": "none" + } + } +} \ No newline at end of file diff --git a/.docker/pulseaudio.pa b/.docker/pulseaudio.pa new file mode 100644 index 0000000..62ba416 --- /dev/null +++ b/.docker/pulseaudio.pa @@ -0,0 +1,4 @@ +unload-module module-suspend-on-idle + +# Allow pulse audio to be accessed via TCP (from localhost only), to allow other users to access the virtual devices +load-module module-native-protocol-unix socket=/tmp/pulseaudio.socket auth-anonymous=1 \ No newline at end of file diff --git a/.docker/supervisord.conf b/.docker/supervisord.conf new file mode 100644 index 0000000..2b38f39 --- /dev/null +++ b/.docker/supervisord.conf @@ -0,0 +1,32 @@ +[supervisord] +environment=PULSE_SERVER="unix:/tmp/pulseaudio.socket",DISPLAY=":%(ENV_NEKO_DISPLAY)s" +nodaemon=true +pidfile=/var/run/supervisord.pid +logfile=/dev/null +logfile_maxbytes=0 + +[program:xvfb] +command=/usr/bin/Xvfb :%(ENV_NEKO_DISPLAY)s -screen 0 %(ENV_NEKO_WIDTH)sx%(ENV_NEKO_HEIGHT)sx24+32 +redirect_stderr=true +autorestart=true +priority=300 + +[program:pulseaudio] +command=/usr/bin/pulseaudio --disallow-module-loading -vvvv --disallow-exit --exit-idle-time=-1 --file=/etc/neko/pulseaudio.pa +autorestart=true +priority=300 + +[program:openbox] +command=/usr/bin/openbox --config-file /etc/neko/openbox.xml +autorestart=true +priority=300 + +[program:firefox-esr] +command=/usr/lib/firefox-esr/firefox-esr --display=:%(ENV_NEKO_DISPLAY)s --setDefaultBrowser -width %(ENV_NEKO_WIDTH)s -height %(ENV_NEKO_HEIGHT)s %(ENV_NEKO_URL)s +autorestart=true +priority=400 + +[program:neko] +command=/usr/bin/neko serve -d --static "/var/www" +autorestart=true +priority=500 \ No newline at end of file diff --git a/.docker/supervisord.dev.conf b/.docker/supervisord.dev.conf new file mode 100644 index 0000000..9bf4927 --- /dev/null +++ b/.docker/supervisord.dev.conf @@ -0,0 +1,28 @@ +[supervisord] +environment=PULSE_SERVER="unix:/tmp/pulseaudio.socket",DISPLAY=":%(ENV_NEKO_DISPLAY)s" +nodaemon=true +pidfile=/var/run/supervisord.pid +#logfile=/dev/null +#logfile_maxbytes=0 +loglevel=debug + +[program:xvfb] +command=/usr/bin/Xvfb :%(ENV_NEKO_DISPLAY)s -screen 0 %(ENV_NEKO_WIDTH)sx%(ENV_NEKO_HEIGHT)sx24+32 +redirect_stderr=true +autorestart=true +priority=300 + +[program:pulseaudio] +command=/usr/bin/pulseaudio --disallow-module-loading -vvvv --disallow-exit --exit-idle-time=-1 --file=/etc/neko/pulseaudio.pa +autorestart=true +priority=300 + +[program:openbox] +command=/usr/bin/openbox --config-file /etc/neko/openbox.xml +autorestart=true +priority=300 + +[program:firefox-esr] +command=/usr/lib/firefox-esr/firefox-esr --display=:%(ENV_NEKO_DISPLAY)s --setDefaultBrowser -width %(ENV_NEKO_WIDTH)s -height %(ENV_NEKO_HEIGHT)s %(ENV_NEKO_URL)s +autorestart=true +priority=400 \ No newline at end of file diff --git a/.docker/test.sh b/.docker/test.sh new file mode 100755 index 0000000..41857d6 --- /dev/null +++ b/.docker/test.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# usefull debugging tools pavucontrol htop x11vnc + +sudo mkdir -p /var/run/dbus /etc/neko +sudo /etc/init.d/dbus start + +sudo cp supervisord.conf /etc/neko/supervisord.conf +sudo cp pulseaudio.pa /etc/neko/pulseaudio.pa +sudo cp openbox.xml /etc/neko/openbox.xml +sudo cp policies.json /usr/lib/firefox-esr/distribution/policies.json + +if [ ! -f /usr/lib/firefox-esr/distribution/extensions/uBlock0@raymondhill.net.xpi ]; then + sudo mkdir -p /usr/lib/firefox-esr/distribution/extensions + sudo curl -o /usr/lib/firefox-esr/distribution/extensions/uBlock0@raymondhill.net.xpi https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/addon-607454-latest.xpi +fi + +if [ ! -f ../server/bin/neko ]; then + echo "build server before testing" + exit 1 +fi + +if [ ! -d ../client/dist/ ]; then + echo "build client before testing" + exit 1 +fi + +sudo cp ../server/bin/neko /usr/bin/neko +sudo cp -R ../client/dist /var/www/ + +sudo rm -rf $HOME/.mozilla +sudo rm -rf /var/run/supervisord.pid + +mkdir -p $HOME/.config/pulse +echo "default-server=unix:/tmp/pulseaudio.socket" > $HOME/.config/pulse/client.conf + +export NEKO_DISPLAY=0 +export NEKO_WIDTH=1280 +export NEKO_HEIGHT=720 +export NEKO_URL=https://www.youtube.com/embed/QH2-TGUlwu4 +export NEKO_PASSWORD=neko +export NEKO_BIND=0.0.0.0:80 +export NEKO_KEY= +export NEKO_CERT= + +supervisord --configuration ./supervisord.dev.conf \ No newline at end of file diff --git a/.docker/x11vnc.sh b/.docker/x11vnc.sh new file mode 100755 index 0000000..066f347 --- /dev/null +++ b/.docker/x11vnc.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ ! -f "${HOME}/.vnc/passwd" ]; then + x11vnc -storepasswd +fi + +/usr/bin/x11vnc -display :0 -6 -xkb -rfbport 5901 -rfbauth $HOME/.vnc/passwd -wait 20 -nap -noxrecord -nopw -noxfixes -noxdamage -repeat diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bab2215 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +* text=auto +*.css text +*.js text +*.ts text +*.json text +*.htm text +*.html text +*.env text +*.xml text +*.svg text +*.txt text +*.ini text +*.sql text +*.sh text +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.mov binary +*.mp4 binary +*.mp3 binary \ No newline at end of file diff --git a/.github/.gitkeep b/.github/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..914f0ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Log/Temp files +.tmp/ +tmp/ +*.tmp +.logs/ +logs/ +*.log +core + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Lock files +yarn.lock +package-lock.json + +# TypeScript incremental compilation cache +*.tsbuildinfo + +# Node modules +node_modules +dist +bin + +# Environment files +*.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..007524f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +FROM buildpack-deps:stretch + +ARG USERNAME=neko +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# Avoid warnings by switching to noninteractive +ENV DEBIAN_FRONTEND=noninteractive + +# Install dependencies +RUN apt-get update \ + && apt-get -y install curl supervisor openbox dbus-x11 ttf-freefont xvfb pulseaudio consolekit firefox-esr \ + && apt-get -y install gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-pulseaudio libxcb-xkb-dev libxkbcommon-x11-dev \ + # + # 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 \ + # + # Install uBlock + && mkdir -p /usr/lib/firefox-esr/distribution/extensions \ + && curl -o /usr/lib/firefox-esr/distribution/extensions/uBlock0@raymondhill.net.xpi https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/addon-607454-latest.xpi \ + # + # Make directories for neko + && mkdir -p /etc/neko /var/www \ + # + # Setup Pulse Audio + mkdir -p /home/$USERNAME/.config/pulse/ \ + && echo "default-server=unix:/tmp/pulseaudio.socket" > /home/$USERNAME/.config/pulse/client.conf \ + && chown -R $USERNAME:$USERNAME /home/$USERNAME \ + # + # Clean up + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* + +# +# Copy configuation files +COPY .docker/pulseaudio.pa /etc/neko/pulseaudio.pa +COPY .docker/openbox.xml /etc/neko/openbox.xml +COPY .docker/supervisord.conf /etc/neko/supervisord.conf +COPY .docker/policies.json /usr/lib/firefox-esr/distribution/policies.json + +# +# Neko files +COPY client/dist/ /var/www +COPY server/bin/neko /usr/bin/neko + +# +# Neko Env +ENV NEKO_USER=$USERNAME +ENV NEKO_DISPLAY=0 +ENV NEKO_WIDTH=1280 +ENV NEKO_HEIGHT=720 +ENV NEKO_URL=https://www.youtube.com/embed/QH2-TGUlwu4 +ENV NEKO_PASSWORD=neko +ENV NEKO_BIND=0.0.0.0:80 +ENV NEKO_KEY= +ENV NEKO_CERT= + +# +# Copy entrypoint +COPY .docker/entrypoint.sh /entrypoint.sh + +# +# Run entrypoint +CMD ["/bin/bash", "/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9eabea0 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# n.eko +This is a proof of concept project I threw together over the last few days, its ugly its not perfect but it looks nice. This uses web rtc to stream a desktop inside of a docker container, I made this because [rabb.it](https://en.wikipedia.org/wiki/Rabb.it) went under and my internet can't handle streaming and discord keeps crashing. I just want to watch anime with my friends ლ(ಠ益ಠლ) so I started digging throughout the net and found a few *kinda* clones, but non of them had the virtual browser, then I found [Turtus](https://github.com/Khauri/Turtus) and I was able to figure out the rest. + +This is by no means a fully featured clone of rabbit. It has no concept of other peers. It has bugs, but for the most part it works. I'm not sure what the future holds for this. If I continue to use it and like it, I'll probably keep pushing updates to it. I'd be happy to accept PRs for any improvements. + +### Why n.eko? +I like cats, I'm a weeb and a nerd, I own the domain [n.eko.moe](https://n.eko.moe/) and I love that logo I came across, had to use it for something /shrug + +### I need help setting this up! +Its a docker container, you need to have docker installed and then run + +``` +TODO: +``` + +### Development +*Highly* recommend you use a dev container for vscode, I've included the .devcontainer I've used to develop this app \ No newline at end of file diff --git a/client/.browserslistrc b/client/.browserslistrc new file mode 100644 index 0000000..d6471a3 --- /dev/null +++ b/client/.browserslistrc @@ -0,0 +1,2 @@ +> 1% +last 2 versions diff --git a/client/.editorconfig b/client/.editorconfig new file mode 100644 index 0000000..3dce414 --- /dev/null +++ b/client/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/client/.eslintrc b/client/.eslintrc new file mode 100644 index 0000000..05bd58d --- /dev/null +++ b/client/.eslintrc @@ -0,0 +1,16 @@ +{ + "root": true, + "env": { + "node": true + }, + "extends": ["plugin:vue/essential", "@vue/prettier", "@vue/typescript"], + "parserOptions": { + "parser": "@typescript-eslint/parser" + }, + "rules": { + "no-case-declarations": "off", + "no-dupe-class-members": "off", + "no-console": "off", + } +} + diff --git a/client/.prettierrc b/client/.prettierrc new file mode 100644 index 0000000..83fc071 --- /dev/null +++ b/client/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "vueIndentScriptAndStyle": true +} diff --git a/client/.vscode/settings.json b/client/.vscode/settings.json new file mode 100644 index 0000000..190a5e5 --- /dev/null +++ b/client/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "files.encoding": "utf8", + "files.eol": "\n", + "typescript.tsdk": "./node_modules/typescript/lib", + "todo-tree.filtering.excludeGlobs": ["**/node_modules/**"], + "eslint.validate": [ + "vue", + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + ], + "vetur.validation.template": true, + "vetur.useWorkspaceDependencies": true, + "remote.extensionKind": { + "ms-azuretools.vscode-docker": "ui" + }, + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } +} diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..e69de29 diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..a44753e --- /dev/null +++ b/client/package.json @@ -0,0 +1,34 @@ +{ + "name": "neko-client", + "version": "1.0.0", + "description": "Client for neko streaming server", + "scripts": { + "serve": "vue-cli-service serve", + "build": "vue-cli-service build", + "lint": "vue-cli-service lint" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^5.12.0", + "eventemitter3": "^4.0.0", + "vue": "^2.6.10", + "vue-class-component": "^7.0.2", + "vue-notification": "^1.3.20", + "vue-property-decorator": "^8.3.0" + }, + "devDependencies": { + "@vue/cli-plugin-eslint": "^4.1.0", + "@vue/cli-plugin-typescript": "^4.1.0", + "@vue/cli-plugin-vuex": "^4.1.0", + "@vue/cli-service": "^4.1.0", + "@vue/eslint-config-prettier": "^5.0.0", + "@vue/eslint-config-typescript": "^4.0.0", + "eslint": "^5.16.0", + "eslint-plugin-prettier": "^3.1.1", + "eslint-plugin-vue": "^5.0.0", + "node-sass": "^4.12.0", + "prettier": "^1.19.1", + "sass-loader": "^8.0.0", + "typescript": "~3.5.3", + "vue-template-compiler": "^2.6.10" + } +} diff --git a/client/public/android-chrome-192x192.png b/client/public/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..70fb69b5d0cd6b326d20dc5dd95c9a4acd36e727 GIT binary patch literal 5433 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 JwHk=%{{sE@{;dE2 literal 0 HcmV?d00001 diff --git a/client/public/android-chrome-512x512.png b/client/public/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..ea2349795f321b83e9006cbeea8679b36c93934c GIT binary patch literal 16860 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

1&nJ?EW2d*=fOVwrxpBea0h{+|k%E*U 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 U?B%melnOviNn5f0rB(R<0*WLp?*IS* literal 0 HcmV?d00001 diff --git a/client/public/apple-touch-icon.png b/client/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d898e03e794c4edacc5e501ac5840bada5e6b93e GIT binary patch literal 4080 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 literal 0 HcmV?d00001 diff --git a/client/public/browserconfig.xml b/client/public/browserconfig.xml new file mode 100644 index 0000000..ededce1 --- /dev/null +++ b/client/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #19bd9c + + + diff --git a/client/public/favicon-16x16.png b/client/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..eca620d9f2871ede6e2fab882851ec701b76b4fd GIT binary patch literal 662 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+ueoXe|!I#{XiaP zfk$L9P%svR84p&!z6=y(FY)wsWq-oR%P6Y(>ztSv0|O(Yr;B5V#`)5ThTcw$B5wU* z$rDvtL|Zz&cYAwp+1eKE>lomi`?ozKLtjcdN+~OYt9jcI$G|x}alx)GF1kv#{)dg9 zc-CEhTKBx@=R4LjyPugHe$pmCt+p&rBA`svrsQ|Mz2{u6fMo*G(uKyWg!Ud23C){e z;(IQ$@R(J%j=JA3v&IUFfu!@a?2%!EycK2lI@Z{iR>Co;&t?KUd}USIvbF z-UGu&wZt`|BqgyV)hf9t6-Y4{85kMp8W`#t8iyDdS{WHynHXvV83qQeD?fOlXvob^ z$xN%nt>McTk8eN?k{}y`^V3So6N^$A%FE03GV`*FlM@S4_413-XTP(N0xAlx3W+EQ zN-S3>D9TUE%t=)!sVqoU$Sf#HW?-n8^Y{}FM`4(T#wq{PXFQ(m_pwD+_y1 z7GV}vaA`0(oWiWUIYi;~jVmXPoH-(Mg#C1b#{w@shF9W(C7+y3rvj~D@O1TaS?83{ F1OU2Q?AZVS literal 0 HcmV?d00001 diff --git a/client/public/favicon-32x32.png b/client/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..6f0007de513fd274ed9e12c5b5053c555d38ec67 GIT binary patch literal 1003 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{Rty4qo`K$arZSqO<|rcjv*e$mrnKd2nm!qdVcoI zt}{~>uuK$ll4n-DlC`OI!Za4g1tQxx4{1bQQOXJI-lzOaL12+ zsk{35OQYl7<`;bCoz7!yuQ~G5#OU4gee4noLKby!eG5EmwYrpdJ|l4sZl-e)Z#Uq+6`m zQ`@C8pG>qoWTLh+W#c-Y)vgotF8`TxjYsUQ&Eok>62BJnX8BxJ*nKlI?M`>`l+TBc z2p4BP=>7eWd)CZz2^TzPciy|`)f*o7LhO0F-2<(uCBjdaTu*q<^u)MBn0Z0iCjb9c zx;%!hBGaY>bTs4!JkXwfVMgboNlRL!OqOZgPUu|k81q)G$YiB@m&MjeQEx1F*quM| ze&+YE-?z9U`I`5M{t214WJb}~i=N9ae-p5aJTD@&>%CuW#3kjg7V~}|FsoR-WJzXh z;P#+e9?knM@>@K8Z>^ht@6Z}W3*|jwGtFMl7EW90HAOkofj>>n zD9TUE%t=)!sVqoU$Sf#HW?-n8^Y{}FM`4(T#wq{PXFQ(m_pwD+_y17GV}v zaA`0(oWiWUIYi;~jVmXPoH-(Mg#C1b#{w@shF9W(C7+y3rvj~D@O1TaS?83{1OSVG Bl_dZG literal 0 HcmV?d00001 diff --git a/client/public/index.html b/client/public/index.html new file mode 100644 index 0000000..9d4135b --- /dev/null +++ b/client/public/index.html @@ -0,0 +1,23 @@ + + + + + + + n.eko + + + + + + + + + + +

+ + + diff --git a/client/public/mstile-144x144.png b/client/public/mstile-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..979c7ddccc81afa010580886e72f86d0410fe864 GIT binary patch literal 3817 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 literal 0 HcmV?d00001 diff --git a/client/public/mstile-310x150.png b/client/public/mstile-310x150.png new file mode 100644 index 0000000000000000000000000000000000000000..6d1eb317d862669cba3ab6fb4a63a0b9eef5b43a GIT binary patch literal 4113 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 dBU + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + diff --git a/client/public/site.webmanifest b/client/public/site.webmanifest new file mode 100644 index 0000000..54ae740 --- /dev/null +++ b/client/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "n.eko", + "short_name": "n.eko", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#19bd9c", + "background_color": "#19bd9c", + "display": "standalone" +} diff --git a/client/src/App.vue b/client/src/App.vue new file mode 100644 index 0000000..dfcf29c --- /dev/null +++ b/client/src/App.vue @@ -0,0 +1,847 @@ + + + + + diff --git a/client/src/assets/logo.svg b/client/src/assets/logo.svg new file mode 100644 index 0000000..fb82e0b --- /dev/null +++ b/client/src/assets/logo.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/client/src/assets/styles/_reset.scss b/client/src/assets/styles/_reset.scss new file mode 100644 index 0000000..767c249 --- /dev/null +++ b/client/src/assets/styles/_reset.scss @@ -0,0 +1,358 @@ +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font: inherit; + font-size: 100%; + vertical-align: baseline; +} + +/* make sure to set some focus styles for accessibility */ +:focus { + outline: 0; +} + +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +body { + line-height: 1; +} + +ol, ul { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +input[type=search]::-webkit-search-cancel-button, +input[type=search]::-webkit-search-decoration, +input[type=search]::-webkit-search-results-button, +input[type=search]::-webkit-search-results-decoration { + -webkit-appearance: none; + -moz-appearance: none; +} + +input[type=search] { + -webkit-appearance: none; + -moz-appearance: none; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +textarea { + overflow: auto; + vertical-align: top; + resize: vertical; +} + +/** +* Correct `inline-block` display not defined in IE 6/7/8/9 and Firefox 3. +*/ + +audio, +canvas, +video { + display: inline-block; + *display: inline; + *zoom: 1; + max-width: 100%; +} + +/** +* Prevent modern browsers from displaying `audio` without controls. +* Remove excess height in iOS 5 devices. +*/ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** +* Address styling not present in IE 7/8/9, Firefox 3, and Safari 4. +* Known issue: no IE 6 support. +*/ + +[hidden] { + display: none; +} + +/** +* 1. Correct text resizing oddly in IE 6/7 when body `font-size` is set using +* `em` units. +* 2. Prevent iOS text size adjust after orientation change, without disabling +* user zoom. +*/ + +html { + font-size: 100%; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + -ms-text-size-adjust: 100%; /* 2 */ +} + +/** +* Address `outline` inconsistency between Chrome and other browsers. +*/ + +a:focus { + outline: none; +} + +/** +* Improve readability when focused and also mouse hovered in all browsers. +*/ + +a:active, +a:hover { + outline: 0; +} + +/** +* 1. Remove border when inside `a` element in IE 6/7/8/9 and Firefox 3. +* 2. Improve image quality when scaled in IE 7. +*/ + +img { + border: 0; /* 1 */ + -ms-interpolation-mode: bicubic; /* 2 */ +} + +/** +* Address margin not present in IE 6/7/8/9, Safari 5, and Opera 11. +*/ + +figure { + margin: 0; +} + +/** +* Correct margin displayed oddly in IE 6/7. +*/ + +form { + margin: 0; +} + +/** +* Define consistent border, margin, and padding. +*/ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** +* 1. Correct color not being inherited in IE 6/7/8/9. +* 2. Correct text not wrapping in Firefox 3. +* 3. Correct alignment displayed oddly in IE 6/7. +*/ + +legend { + border: 0; /* 1 */ + padding: 0; + white-space: normal; /* 2 */ + *margin-left: -7px; /* 3 */ +} + +/** +* 1. Correct font size not being inherited in all browsers. +* 2. Address margins set differently in IE 6/7, Firefox 3+, Safari 5, +* and Chrome. +* 3. Improve appearance and consistency in all browsers. +*/ + +button, +input, +select, +textarea { + font-size: 100%; /* 1 */ + margin: 0; /* 2 */ + vertical-align: baseline; /* 3 */ + *vertical-align: middle; /* 3 */ +} + +/** +* Address Firefox 3+ setting `line-height` on `input` using `!important` in +* the UA stylesheet. +*/ + +button, +input { + line-height: normal; +} + +/** +* Address inconsistent `text-transform` inheritance for `button` and `select`. +* All other form control elements do not inherit `text-transform` values. +* Correct `button` style inheritance in Chrome, Safari 5+, and IE 6+. +* Correct `select` style inheritance in Firefox 4+ and Opera. +*/ + +button, +select { + text-transform: none; +} + +/** +* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` +* and `video` controls. +* 2. Correct inability to style clickable `input` types in iOS. +* 3. Improve usability and consistency of cursor style between image-type +* `input` and others. +* 4. Remove inner spacing in IE 7 without affecting normal text inputs. +* Known issue: inner spacing remains in IE 6. +*/ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ + *overflow: visible; /* 4 */ +} + +/** +* Re-set default cursor for disabled elements. +*/ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** +* 1. Address box sizing set to content-box in IE 8/9. +* 2. Remove excess padding in IE 8/9. +* 3. Remove excess padding in IE 7. +* Known issue: excess padding remains in IE 6. +*/ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ + *height: 13px; /* 3 */ + *width: 13px; /* 3 */ +} + +/** +* 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. +* 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome +* (include `-moz` to future-proof). +*/ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; /* 2 */ + box-sizing: content-box; +} + +/** +* Remove inner padding and search cancel button in Safari 5 and Chrome +* on OS X. +*/ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** +* Remove inner padding and border in Firefox 3+. +*/ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** +* 1. Remove default vertical scrollbar in IE 6/7/8/9. +* 2. Improve readability and alignment in all browsers. +*/ + +textarea { + overflow: auto; /* 1 */ + vertical-align: top; /* 2 */ +} + +/** +* Remove most spacing between table cells. +*/ + +table { + border-collapse: collapse; + border-spacing: 0; +} + +html, +button, +input, +select, +textarea { + color: #222; +} + +::-moz-selection { + text-shadow: none; +} + +::selection { + text-shadow: none; +} + +img { + vertical-align: middle; +} + +fieldset { + border: 0; + margin: 0; + padding: 0; +} + +textarea { + resize: vertical; +} + +.chromeframe { + margin: 0.2em 0; + background: #ccc; + color: #000; + padding: 0.2em 0; +} diff --git a/client/src/assets/styles/_variables.scss b/client/src/assets/styles/_variables.scss new file mode 100644 index 0000000..82490f3 --- /dev/null +++ b/client/src/assets/styles/_variables.scss @@ -0,0 +1,10 @@ + + +$style-dark: #2c2c2c; +$style-darker: #1a1a1a; +$style-light: #fafafa; +$style-primary: #19bd9c; + +$style-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; +$style-font-color: $style-dark; +$style-font-size: 14px; diff --git a/client/src/assets/styles/main.scss b/client/src/assets/styles/main.scss new file mode 100644 index 0000000..6a321ad --- /dev/null +++ b/client/src/assets/styles/main.scss @@ -0,0 +1,23 @@ +@charset "utf-8"; + +// Import variables +@import "variables"; + +// Reset CSS +@import "reset"; + +// Import Vendor +@import "vendor/font-awesome"; + +html, body { + -webkit-font-smoothing: subpixel-antialiased; + background-color: $style-dark; + font-family: $style-font-family; + font-size: $style-font-size; + color: $style-font-color; + overflow: hidden; + width: 100vw; + height: 100vh; + min-width: 320px; +} + diff --git a/client/src/assets/styles/vendor/_font-awesome.scss b/client/src/assets/styles/vendor/_font-awesome.scss new file mode 100644 index 0000000..1ea360b --- /dev/null +++ b/client/src/assets/styles/vendor/_font-awesome.scss @@ -0,0 +1,20 @@ +// Variables + +$fa-font-path: "~@fortawesome/fontawesome-free/webfonts"; +$fa-font-size-base: 16px; +$fa-font-display: auto; +$fa-css-prefix: fa; +$fa-border-color: #eee; +$fa-inverse: #fff; +$fa-li-width: 2em; +$fa-fw-width: (20em / 16); +$fa-primary-opacity: 1; +$fa-secondary-opacity: .4; + +$fa-family-default: 'Font Awesome 5 Free'; + +// Import FA source files +@import "~@fortawesome/fontawesome-free/scss/brands"; +@import "~@fortawesome/fontawesome-free/scss/solid"; +@import "~@fortawesome/fontawesome-free/scss/regular"; +@import "~@fortawesome/fontawesome-free/scss/fontawesome"; diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..b44d343 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,12 @@ +import './assets/styles/main.scss' + +import Vue from 'vue' +import Notifications from 'vue-notification' +import App from './App.vue' + +Vue.config.productionTip = false +Vue.use(Notifications) + +new Vue({ + render: h => h(App), +}).$mount('#neko') diff --git a/client/src/types/shims-scss.d.ts b/client/src/types/shims-scss.d.ts new file mode 100644 index 0000000..5e761af --- /dev/null +++ b/client/src/types/shims-scss.d.ts @@ -0,0 +1 @@ +declare module '*.scss' {} diff --git a/client/src/types/shims-tsx.d.ts b/client/src/types/shims-tsx.d.ts new file mode 100644 index 0000000..c656c68 --- /dev/null +++ b/client/src/types/shims-tsx.d.ts @@ -0,0 +1,13 @@ +import Vue, { VNode } from 'vue' + +declare global { + namespace JSX { + // tslint:disable no-empty-interface + interface Element extends VNode {} + // tslint:disable no-empty-interface + interface ElementClass extends Vue {} + interface IntrinsicElements { + [elem: string]: any + } + } +} diff --git a/client/src/types/shims-vue.d.ts b/client/src/types/shims-vue.d.ts new file mode 100644 index 0000000..d9f24fa --- /dev/null +++ b/client/src/types/shims-vue.d.ts @@ -0,0 +1,4 @@ +declare module '*.vue' { + import Vue from 'vue' + export default Vue +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..41a3a62 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "esnext", + "strict": true, + "jsx": "preserve", + "importHelpers": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "baseUrl": ".", + "types": [ + "webpack-env" + ], + "paths": { + "@/*": [ + "src/*" + ] + }, + "lib": [ + "esnext", + "dom", + "dom.iterable", + "scripthost" + ] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.vue", + ], + "exclude": [ + "node_modules" + ] +} diff --git a/client/vue.config.js b/client/vue.config.js new file mode 100644 index 0000000..a3ae613 --- /dev/null +++ b/client/vue.config.js @@ -0,0 +1,11 @@ +module.exports = { + css: { + loaderOptions: { + sass: { + prependData: ` + @import "@/assets/styles/_variables.scss"; + `, + }, + }, + }, +} diff --git a/neko.code-workspace b/neko.code-workspace new file mode 100644 index 0000000..ad01bca --- /dev/null +++ b/neko.code-workspace @@ -0,0 +1,47 @@ +{ + "folders": [ + { + "path": "server" + }, + { + "path": "client" + }, + { + "path": "." + } + ], + "settings": { + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "files.encoding": "utf8", + "files.eol": "\n", + "typescript.tsdk": "./client/node_modules/typescript/lib", + "todo-tree.filtering.excludeGlobs": ["**/node_modules/**"], + "eslint.validate": [ + "vue", + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + ], + "vetur.validation.template": true, + "vetur.useWorkspaceDependencies": true, + "remote.extensionKind": { + "ms-azuretools.vscode-docker": "ui" + }, + "remote.containers.defaultExtensions": [ + "ms-vscode.go", + "octref.vetur", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-vscode-remote.vscode-remote-extensionpack", + "ms-vscode-remote.remote-containers", + "ms-azuretools.vscode-docker", + "editorconfig.editorconfig", + "gruntfuggly.todo-tree", + "swyphcosmo.spellchecker", + "eamodio.gitlens" + ] + } +} \ No newline at end of file diff --git a/server/.editorconfig b/server/.editorconfig new file mode 100644 index 0000000..3dce414 --- /dev/null +++ b/server/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json new file mode 100644 index 0000000..acee218 --- /dev/null +++ b/server/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "launch", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/neko", + "envFile": "${workspaceFolder}/.env", + "output": "${workspaceFolder}/bin/debug/neko", + "cwd": "${workspaceFolder}/", + "args": ["serve", "-d", "--bind", "0.0.0.0:3000", "--static", "../client/dist", "--password", "test"] + } + ] +} diff --git a/server/.vscode/settings.json b/server/.vscode/settings.json new file mode 100644 index 0000000..452ccec --- /dev/null +++ b/server/.vscode/settings.json @@ -0,0 +1,22 @@ + +{ + "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 + } + } + } diff --git a/server/Makefile b/server/Makefile new file mode 100644 index 0000000..36ac540 --- /dev/null +++ b/server/Makefile @@ -0,0 +1,9 @@ +BUILD_TIME=`date -u +'%Y-%m-%dT%H:%M:%SZ'` +GIT_COMMIT=`git rev-parse --short HEAD` +GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD` +GIT_DIRTY=`git diff-index --quiet HEAD -- || echo "✗-"` + +LDFLAGS=-ldflags "-s -X version.buildTime=${BUILD_TIME} -X version.gitRevision=${GIT_DIRTY}${GIT_REVISION} -X version.gitBranch=${GIT_BRANCH}" + +build: + go build -o bin/neko ${LDFLAGS} -i cmd/neko/main.go diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..e69de29 diff --git a/server/cmd/neko/main.go b/server/cmd/neko/main.go new file mode 100644 index 0000000..bcdde3a --- /dev/null +++ b/server/cmd/neko/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + + "n.eko.moe/neko" + "n.eko.moe/neko/cmd" + "n.eko.moe/neko/internal/utils" + + "github.com/rs/zerolog/log" +) + +func main() { + fmt.Print(utils.Colorf(utils.Header, "server", neko.Service.Version)) + if err := cmd.Execute(); err != nil { + log.Panic().Err(err).Msg("Failed to execute command") + } +} diff --git a/server/cmd/root.go b/server/cmd/root.go new file mode 100644 index 0000000..ed6f57e --- /dev/null +++ b/server/cmd/root.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + + "n.eko.moe/neko" + "n.eko.moe/neko/internal/preflight" + + "github.com/spf13/cobra" +) + +func Execute() error { + return root.Execute() +} + +var root = &cobra.Command{ + Use: "neko", + Short: "", + Long: ``, + Version: neko.Service.Version.String(), +} + +func init() { + cobra.OnInitialize(func() { + preflight.Logs("neko") + preflight.Config("neko") + neko.Service.Root.Set() + }) + + if err := neko.Service.Root.Init(root); err != nil { + neko.Service.Logger.Panic().Err(err).Msg("Unable to run command") + } + + root.SetVersionTemplate(fmt.Sprintf("Version: %s\n", neko.Service.Version)) +} diff --git a/server/cmd/serve.go b/server/cmd/serve.go new file mode 100644 index 0000000..255ec8a --- /dev/null +++ b/server/cmd/serve.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "n.eko.moe/neko" + "n.eko.moe/neko/internal/config" +) + +func init() { + command := &cobra.Command{ + Use: "serve", + Short: "", + Long: ``, + Run: neko.Service.ServeCommand, + } + + configs := []config.Config{ + neko.Service.Serve, + } + + 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 command") + } + } + + root.AddCommand(command) +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..b3bbc47 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,15 @@ +module n.eko.moe/neko + +go 1.13 + +require ( + github.com/go-chi/chi v4.0.3+incompatible + github.com/go-vgo/robotgo v0.0.0-20200111145433-6e6028a14d57 + github.com/gorilla/websocket v1.4.1 + github.com/matoous/go-nanoid v1.1.0 + github.com/pion/webrtc/v2 v2.1.18 + github.com/pkg/errors v0.8.1 + github.com/rs/zerolog v1.17.2 + github.com/spf13/cobra v0.0.5 + github.com/spf13/viper v1.6.1 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..acc09f3 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,300 @@ +bou.ke/monkey v1.0.1/go.mod h1:FgHuK96Rv2Nlf+0u1OOVDpCMdsWyOFmeeketDHE7LIg= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= +github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY= +github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= +github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-vgo/robotgo v0.0.0-20200111145433-6e6028a14d57 h1:0BtenaNSwWghFTsHDQGTdurEdCMm7lsNmWfMoBS2JiY= +github.com/go-vgo/robotgo v0.0.0-20200111145433-6e6028a14d57/go.mod h1:P6/F9lmSF2Z/74P/m80qEm6ApjE5HmB+rSzfBCNqIPo= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/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 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +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 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9 h1:tbuodUh2vuhOVZAdW3NEUvosFHUMJwUNl7jk/VSEiwc= +github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw= +github.com/lxn/win v0.0.0-20191024121223-cc00c7492fe1 h1:h0wbuSK8xUNmMwDdCxZx2OLdkVck6Bb31zj4CxCN5I4= +github.com/lxn/win v0.0.0-20191024121223-cc00c7492fe1/go.mod h1:ouWl4wViUNh8tPSIwxTVMuS014WakR1hqvBc2I0bMoA= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/marten-seemann/qtls v0.2.3 h1:0yWJ43C62LsZt08vuQJDK1uC1czUc3FJeCLPoNAI4vA= +github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= +github.com/matoous/go-nanoid v1.1.0 h1:B4BSMxTVgYrCHqtovL/adb8GFkE4mPCNntOOrdZLeCk= +github.com/matoous/go-nanoid v1.1.0/go.mod h1:L+uFUqrYwDgNWu5R02GrSxxcqX7ghiFuKPlKEOZ90GE= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v0.0.0-20190513014714-f5a3d24e5776/go.mod h1:3HNVkVOU7vZeFXocWuvtcS0XSFLcf2XUSDHkq9t1jU4= +github.com/otiai10/gosseract v2.2.1+incompatible h1:Ry5ltVdpdp4LAa2bMjsSJH34XHVOV7XMi41HtzL8X2I= +github.com/otiai10/gosseract v2.2.1+incompatible/go.mod h1:XrzWItCzCpFRZ35n3YtVTgq5bLAhFIkascoRo8G32QE= +github.com/otiai10/mint v1.2.4/go.mod h1:d+b7n/0R3tdyUYYylALXpWQ/kTN+QobSq/4SRGBkR3M= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pion/datachannel v1.4.13 h1:ezTn3AtUtXvKemRRjRdUgao/T8bH4ZJwrpOqU8Iz3Ss= +github.com/pion/datachannel v1.4.13/go.mod h1:+rBUwEDonA63KXx994DP/ofyyGVAm6AIMvOqQZxjWRU= +github.com/pion/dtls/v2 v2.0.0-rc.3 h1:u9utI+EDJOjOWfrkGQsD8WNssPcTwfYIanFB6oI8K+4= +github.com/pion/dtls/v2 v2.0.0-rc.3/go.mod h1:x0XH+cN5z+l/+/4nYL8r4sB8g6+0d1Zp2Pfkcoz8BKY= +github.com/pion/ice v0.7.6 h1:EARj1MBq5NYaMtXVhYkK03i0RS/meejNHvZS++K5tSY= +github.com/pion/ice v0.7.6/go.mod h1:4xCajahEEvc5w0AM+Ujx/Rr2EczON/fKndi3jLyDdh4= +github.com/pion/logging v0.2.1/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +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.4 h1:O4vvVqr4DGX63vzmO6Fw9vpy3lfztVWHGCQfyw0ZLSY= +github.com/pion/mdns v0.0.4/go.mod h1:R1sL0p50l42S5lJs91oNdUL58nm0QHrhxnSegr++qC0= +github.com/pion/quic v0.1.1 h1:D951FV+TOqI9A0rTF7tHx0Loooqz+nyzjEyj8o3PuMA= +github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k= +github.com/pion/rtcp v1.2.1 h1:S3yG4KpYAiSmBVqKAfgRa5JdwBNj4zK3RLUa8JYdhak= +github.com/pion/rtcp v1.2.1/go.mod h1:a5dj2d6BKIKHl43EnAOIrCczcjESrtPuMgfmL6/K6QM= +github.com/pion/rtp v1.1.3/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= +github.com/pion/rtp v1.1.4 h1:P6xh8Y8JfzR7+JAbI79X2M8kfYETaqbuM5Otm+Z+k6U= +github.com/pion/rtp v1.1.4/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= +github.com/pion/sctp v1.7.3 h1:Pok18oncuAq/WjNxbyltfBSLvbv/6QSCyVJKYyDWP5M= +github.com/pion/sctp v1.7.3/go.mod h1:c6C9jaDGX7f5xeSRVju/140XatpO9sOVe81EwpfzAc8= +github.com/pion/sdp/v2 v2.3.1 h1:45dub4NRdwyDmQCD3GIY7DZuqC49GBUwBdjuetvdOr0= +github.com/pion/sdp/v2 v2.3.1/go.mod h1:jccXVYW0fuK6ds2pwKr89SVBDYlCjhgMI6nucl5R5rA= +github.com/pion/srtp v1.2.6 h1:mHQuAMh0P67R7/j1F260u3O+fbRWLyjKLRPZYYvODFM= +github.com/pion/srtp v1.2.6/go.mod h1:rd8imc5htjfs99XiEoOjLMEOcVjME63UHx9Ek9IGst0= +github.com/pion/stun v0.3.3 h1:brYuPl9bN9w/VM7OdNzRSLoqsnwlyNvD9MVeJrHjDQw= +github.com/pion/stun v0.3.3/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= +github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE= +github.com/pion/transport v0.8.9/go.mod h1:lpeSM6KJFejVtZf8k0fgeN7zE73APQpTF83WvA1FVP8= +github.com/pion/transport v0.8.10 h1:lTiobMEw2PG6BH/mgIVqTV2mBp/mPT+IJLaN8ZxgdHk= +github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8= +github.com/pion/turn v1.4.0 h1:7NUMRehQz4fIo53Qv9ui1kJ0Kr1CA82I81RHKHCeM80= +github.com/pion/turn v1.4.0/go.mod h1:aDSi6hWX/hd1+gKia9cExZOR0MU95O7zX9p3Gw/P2aU= +github.com/pion/webrtc/v2 v2.1.18 h1:g0VN0xfEUSlVNfQmlCD6yOeXy/tMaktESBmHMnBS3bk= +github.com/pion/webrtc/v2 v2.1.18/go.mod h1:m0rKlYgLRZWyhmcMWegpF6xtK1ASxmOg8DAR74ttzQY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/robotn/gohook v0.0.0-20191208195706-98eb507a75d9 h1:7vKLsPiS3XTrejZHaoKDS3/26K3t10rAtuf1+fRfIVA= +github.com/robotn/gohook v0.0.0-20191208195706-98eb507a75d9/go.mod h1:n1o8s7fg6QGcgIsN9AmWQnBi6KrcbEUX0kFVUwTP53g= +github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934 h1:2lhSR8N3T6I30q096DT7/5AKEIcf1vvnnWAmS0wfnNY= +github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934/go.mod h1:SxQhJskUJ4rleVU44YvnrdvxQr0tKy5SRSigBrCgyyQ= +github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770 h1:2uX8QRLkkxn2EpAQ6I3KhA79BkdRZfvugJUzJadiJwk= +github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770/go.mod h1:svkDXUDQjUiWzLrA0OZgHc4lbOts3C+uRfP6/yjwYnU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo= +github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/shirou/gopsutil v2.19.6+incompatible h1:49/Gru26Lne9Cl3IoAVDZVM09hvkSrUodgIIsCVRwbs= +github.com/shirou/gopsutil v2.19.6+incompatible/go.mod h1:WWnYX4lzhCH5h/3YBfyVA3VbLYjlMZZAQcW9ojMexNc= +github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4 h1:udFKJ0aHUL60LboW/A+DfgoHVedieIzIXE8uylPue0U= +github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.6.1 h1:VPZzIkznI1YhVMRi6vNFLHSwhnhReBfgTxIPccpfdZk= +github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/vcaesar/gops v0.0.0-20190910162627-58ac09d12a53 h1:tYb/9KQi8dTCSDia2NwbuhUbKlaurqC/S7MFQo96nLk= +github.com/vcaesar/gops v0.0.0-20190910162627-58ac09d12a53/go.mod h1:5txYrXKrQG6ZJYdGIiMVVxiOhbdACnwBcHzIwGQ7Nkw= +github.com/vcaesar/imgo v0.0.0-20191008162304-a83ea7753bc8 h1:9Y+hoKBYa+UtzGqkODfs8c0Q6gp2UfniVNsHQWghPi0= +github.com/vcaesar/imgo v0.0.0-20191008162304-a83ea7753bc8/go.mod h1:52+3yYrTNjWKh+CkQozNRCLWCE/X666yAWPGbYC3DZI= +github.com/vcaesar/tt v0.0.0-20191103173835-6896a351024b h1:psGhQitWSo4KBpLghvJPlhHxTJ8LQl1y0ekjSreqvu4= +github.com/vcaesar/tt v0.0.0-20191103173835-6896a351024b/go.mod h1:GHPxQYhn+7OgKakRusH7KJ0M5MhywoeLb8Fcffs/Gtg= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191029031824-8986dd9e96cf/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vKV/xzVTO7XPAwm8xbf4w2g= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/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-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/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-20190522155817-f3200d17e092/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-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/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-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190910064555-bbd175535a8b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab h1:FvshnhkKW+LO3HWHodML8kuVX8rnJTxKm9dFPuI68UM= +golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..43b2695 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,8 @@ +package config + +import "github.com/spf13/cobra" + +type Config interface { + Init(cmd *cobra.Command) error + Set() +} \ No newline at end of file diff --git a/server/internal/config/root.go b/server/internal/config/root.go new file mode 100644 index 0000000..4637026 --- /dev/null +++ b/server/internal/config/root.go @@ -0,0 +1,37 @@ +package config + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type Root struct { + Debug bool + Logs bool + CfgFile string +} + +func (Root) Init(cmd *cobra.Command) error { + 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 { + return err + } + + cmd.PersistentFlags().String("config", "", "Configuration file path") + if err := viper.BindPFlag("config", cmd.PersistentFlags().Lookup("config")); err != nil { + return err + } + + return nil +} + +func (s *Root) Set() { + s.Logs = viper.GetBool("logs") + s.Debug = viper.GetBool("debug") + s.CfgFile = viper.GetString("config") +} diff --git a/server/internal/config/serve.go b/server/internal/config/serve.go new file mode 100644 index 0000000..012f385 --- /dev/null +++ b/server/internal/config/serve.go @@ -0,0 +1,52 @@ +package config + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type Serve struct { + Cert string + Key string + Bind string + Password string + Static string +} + +func (Serve) Init(cmd *cobra.Command) error { + + cmd.PersistentFlags().String("bind", "127.0.0.1:8080", "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") + 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") + if err := viper.BindPFlag("key", cmd.PersistentFlags().Lookup("key")); err != nil { + return err + } + + 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("static", "./www", "Static files to serve") + if err := viper.BindPFlag("static", cmd.PersistentFlags().Lookup("static")); err != nil { + return err + } + + return nil +} + +func (s *Serve) Set() { + s.Cert = viper.GetString("cert") + s.Key = viper.GetString("key") + s.Bind = viper.GetString("bind") + s.Password = viper.GetString("password") + s.Static = viper.GetString("static") +} diff --git a/server/internal/gst/gst.c b/server/internal/gst/gst.c new file mode 100644 index 0000000..64d4cfa --- /dev/null +++ b/server/internal/gst/gst.c @@ -0,0 +1,88 @@ +#include "gst.h" + +#include + +typedef struct SampleHandlerUserData { + int pipelineId; +} SampleHandlerUserData; + +GMainLoop *gstreamer_send_main_loop = NULL; +void gstreamer_send_start_mainloop(void) { + gstreamer_send_main_loop = g_main_loop_new(NULL, FALSE); + + g_main_loop_run(gstreamer_send_main_loop); +} + +static gboolean gstreamer_send_bus_call(GstBus *bus, GstMessage *msg, gpointer data) { + switch (GST_MESSAGE_TYPE(msg)) { + + case GST_MESSAGE_EOS: + g_print("End of stream\n"); + exit(1); + break; + + case GST_MESSAGE_ERROR: { + gchar *debug; + GError *error; + + gst_message_parse_error(msg, &error, &debug); + g_free(debug); + + g_printerr("Error: %s\n", error->message); + g_error_free(error); + exit(1); + } + default: + break; + } + + return TRUE; +} + +GstFlowReturn gstreamer_send_new_sample_handler(GstElement *object, gpointer user_data) { + GstSample *sample = NULL; + GstBuffer *buffer = NULL; + gpointer copy = NULL; + gsize copy_size = 0; + SampleHandlerUserData *s = (SampleHandlerUserData *)user_data; + + g_signal_emit_by_name (object, "pull-sample", &sample); + if (sample) { + buffer = gst_sample_get_buffer(sample); + if (buffer) { + gst_buffer_extract_dup(buffer, 0, gst_buffer_get_size(buffer), ©, ©_size); + goHandlePipelineBuffer(copy, copy_size, GST_BUFFER_DURATION(buffer), s->pipelineId); + } + gst_sample_unref (sample); + } + + return GST_FLOW_OK; +} + +GstElement *gstreamer_send_create_pipeline(char *pipeline) { + gst_init(NULL, NULL); + GError *error = NULL; + return gst_parse_launch(pipeline, &error); +} + +void gstreamer_send_start_pipeline(GstElement *pipeline, int pipelineId) { + SampleHandlerUserData *s = calloc(1, sizeof(SampleHandlerUserData)); + s->pipelineId = pipelineId; + + GstBus *bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); + gst_bus_add_watch(bus, gstreamer_send_bus_call, NULL); + gst_object_unref(bus); + + GstElement *appsink = gst_bin_get_by_name(GST_BIN(pipeline), "appsink"); + g_object_set(appsink, "emit-signals", TRUE, NULL); + g_signal_connect(appsink, "new-sample", G_CALLBACK(gstreamer_send_new_sample_handler), s); + gst_object_unref(appsink); + + gst_element_set_state(pipeline, GST_STATE_PLAYING); +} + +void gstreamer_send_stop_pipeline(GstElement *pipeline) { + gst_element_set_state(pipeline, GST_STATE_NULL); +} + + diff --git a/server/internal/gst/gst.go b/server/internal/gst/gst.go new file mode 100644 index 0000000..e748135 --- /dev/null +++ b/server/internal/gst/gst.go @@ -0,0 +1,125 @@ +package gst + +/* +#cgo pkg-config: gstreamer-1.0 gstreamer-app-1.0 + +#include "gst.h" + +*/ +import "C" +import ( + "fmt" + "io" + "sync" + "unsafe" + + "github.com/pion/webrtc/v2" + "github.com/pion/webrtc/v2/pkg/media" +) + +func init() { + go C.gstreamer_send_start_mainloop() +} + +// Pipeline is a wrapper for a GStreamer Pipeline +type Pipeline struct { + Pipeline *C.GstElement + tracks []*webrtc.Track + id int + codecName string + clockRate float32 +} + +var pipelines = make(map[int]*Pipeline) +var pipelinesLock sync.Mutex + +const ( + videoClockRate = 90000 + audioClockRate = 48000 + pcmClockRate = 8000 +) + +// CreatePipeline creates a GStreamer Pipeline +func CreatePipeline(codecName string, tracks []*webrtc.Track, pipelineSrc string) *Pipeline { + pipelineStr := "appsink name=appsink" + var clockRate float32 + + switch codecName { + case webrtc.VP8: + pipelineStr = pipelineSrc + " ! vp8enc error-resilient=partitions keyframe-max-dist=10 auto-alt-ref=true cpu-used=5 deadline=1 ! " + pipelineStr + clockRate = videoClockRate + + case webrtc.VP9: + pipelineStr = pipelineSrc + " ! vp9enc ! " + pipelineStr + clockRate = videoClockRate + + case webrtc.H264: + pipelineStr = pipelineSrc + " ! video/x-raw,format=I420 ! x264enc bframes=0 speed-preset=veryfast key-int-max=60 ! video/x-h264,stream-format=byte-stream ! " + pipelineStr + clockRate = videoClockRate + + case webrtc.Opus: + pipelineStr = pipelineSrc + " ! opusenc ! " + pipelineStr + clockRate = audioClockRate + + case webrtc.G722: + pipelineStr = pipelineSrc + " ! avenc_g722 ! " + pipelineStr + clockRate = audioClockRate + + case webrtc.PCMU: + pipelineStr = pipelineSrc + " ! audio/x-raw, rate=8000 ! mulawenc ! " + pipelineStr + clockRate = pcmClockRate + + case webrtc.PCMA: + pipelineStr = pipelineSrc + " ! audio/x-raw, rate=8000 ! alawenc ! " + pipelineStr + clockRate = pcmClockRate + + default: + panic("Unhandled codec " + codecName) + } + + pipelineStrUnsafe := C.CString(pipelineStr) + defer C.free(unsafe.Pointer(pipelineStrUnsafe)) + + pipelinesLock.Lock() + defer pipelinesLock.Unlock() + + pipeline := &Pipeline{ + Pipeline: C.gstreamer_send_create_pipeline(pipelineStrUnsafe), + tracks: tracks, + id: len(pipelines), + codecName: codecName, + clockRate: clockRate, + } + + pipelines[pipeline.id] = pipeline + return pipeline +} + +// Start starts the GStreamer Pipeline +func (p *Pipeline) Start() { + C.gstreamer_send_start_pipeline(p.Pipeline, C.int(p.id)) +} + +// Stop stops the GStreamer Pipeline +func (p *Pipeline) Stop() { + C.gstreamer_send_stop_pipeline(p.Pipeline) +} + +//export goHandlePipelineBuffer +func goHandlePipelineBuffer(buffer unsafe.Pointer, bufferLen C.int, duration C.int, pipelineID C.int) { + pipelinesLock.Lock() + pipeline, ok := pipelines[int(pipelineID)] + pipelinesLock.Unlock() + + if ok { + samples := uint32(pipeline.clockRate * (float32(duration) / 1000000000)) + for _, t := range pipeline.tracks { + if err := t.WriteSample(media.Sample{Data: C.GoBytes(buffer, bufferLen), Samples: samples}); err != nil && err != io.ErrClosedPipe { + panic(err) + } + } + } else { + fmt.Printf("discarding buffer, no pipeline with id %d", int(pipelineID)) + } + C.free(buffer) +} diff --git a/server/internal/gst/gst.h b/server/internal/gst/gst.h new file mode 100644 index 0000000..dcdc6ba --- /dev/null +++ b/server/internal/gst/gst.h @@ -0,0 +1,16 @@ +#ifndef GST_H +#define GST_H + +#include +#include +#include +#include + +extern void goHandlePipelineBuffer(void *buffer, int bufferLen, int samples, int pipelineId); + +GstElement *gstreamer_send_create_pipeline(char *pipeline); +void gstreamer_send_start_pipeline(GstElement *pipeline, int pipelineId); +void gstreamer_send_stop_pipeline(GstElement *pipeline); +void gstreamer_send_start_mainloop(void); + +#endif diff --git a/server/internal/http/api.go b/server/internal/http/api.go new file mode 100644 index 0000000..d0f9846 --- /dev/null +++ b/server/internal/http/api.go @@ -0,0 +1,14 @@ +package http + +import ( + "net/http" + + "n.eko.moe/neko/internal/http/handler" +) + +func New(bind, password, static string) *http.Server { + return &http.Server{ + Addr: bind, + Handler: handler.New(password, static), + } +} diff --git a/server/internal/http/endpoint/endpoint.go b/server/internal/http/endpoint/endpoint.go new file mode 100644 index 0000000..0018523 --- /dev/null +++ b/server/internal/http/endpoint/endpoint.go @@ -0,0 +1,102 @@ +package endpoint + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime/debug" + + "github.com/go-chi/chi/middleware" + "github.com/rs/zerolog/log" +) + +type ( + Endpoint func(http.ResponseWriter, *http.Request) error + + ErrResponse struct { + Status int `json:"status,omitempty"` + Err string `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Details string `json:"details,omitempty"` + Code string `json:"code,omitempty"` + RequestID string `json:"request,omitempty"` + } +) + +func Handle(handler Endpoint) http.HandlerFunc { + fn := func(w http.ResponseWriter, r *http.Request) { + if err := handler(w, r); err != nil { + WriteError(w, r, err) + } + } + + return http.HandlerFunc(fn) +} + +var nonErrorsCodes = map[int]bool{ + 404: true, +} + +func errResponse(input interface{}) *ErrResponse { + var res *ErrResponse + var err interface{} + + switch input.(type) { + case *HandlerError: + e := input.(*HandlerError) + res = &ErrResponse{ + Status: e.Status, + Err: http.StatusText(e.Status), + Message: e.Message, + } + err = e.Err + default: + res = &ErrResponse{ + Status: http.StatusInternalServerError, + Err: http.StatusText(http.StatusInternalServerError), + } + err = input + } + + if err != nil { + switch err.(type) { + case *error: + e := err.(error) + res.Details = e.Error() + break + default: + res.Details = fmt.Sprintf("%+v", err) + break + } + } + + return res +} + +func WriteError(w http.ResponseWriter, r *http.Request, err interface{}) { + hlog := log.With(). + Str("module", "http"). + Logger() + + res := errResponse(err) + + if reqID := middleware.GetReqID(r.Context()); reqID != "" { + res.RequestID = reqID + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(res.Status) + + if err := json.NewEncoder(w).Encode(res); err != nil { + hlog.Warn().Err(err).Msg("Failed writing json error response") + } + + if !nonErrorsCodes[res.Status] { + logEntry := middleware.GetLogEntry(r) + if logEntry != nil { + logEntry.Panic(err, debug.Stack()) + } else { + hlog.Error().Str("stack", string(debug.Stack())).Msgf("%+v", err) + } + } +} diff --git a/server/internal/http/endpoint/error.go b/server/internal/http/endpoint/error.go new file mode 100644 index 0000000..3fe3951 --- /dev/null +++ b/server/internal/http/endpoint/error.go @@ -0,0 +1,17 @@ +package endpoint + +import "fmt" + +type HandlerError struct { + Status int + Message string + Err error +} + +func (e *HandlerError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %s", e.Message, e.Err.Error()) + } + + return e.Message +} diff --git a/server/internal/http/handler/handler.go b/server/internal/http/handler/handler.go new file mode 100644 index 0000000..458f6a9 --- /dev/null +++ b/server/internal/http/handler/handler.go @@ -0,0 +1,55 @@ +package handler + +import ( + "fmt" + "net/http" + "os" + + "n.eko.moe/neko/internal/http/middleware" + "n.eko.moe/neko/internal/http/endpoint" + "n.eko.moe/neko/internal/webrtc" + + "github.com/go-chi/chi" +) + +type Handler struct { + router *chi.Mux + manager *webrtc.WebRTCManager +} + +func New(password, static string) *chi.Mux { + router := chi.NewRouter() + manager, err := webrtc.NewManager(password) + if err != nil { + panic(err) + } + + handler := &Handler{ + router: router, + manager: manager, + } + + router.Use(middleware.Recoverer) // Recover from panics without crashing server + // router.Use(middleware.Logger) // Log API request calls + + router.Get("/ping", endpoint.Handle(handler.Ping)) + router.Get("/ws", endpoint.Handle(handler.WebSocket)) + + fs := http.FileServer(http.Dir(static)) + router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + if _, err := os.Stat(static + r.RequestURI); os.IsNotExist(err) { + http.StripPrefix(r.RequestURI, fs).ServeHTTP(w, r) + } else { + fs.ServeHTTP(w, r) + } + }) + + router.NotFound(endpoint.Handle(func(w http.ResponseWriter, r *http.Request) error { + return &endpoint.HandlerError{ + Status: http.StatusNotFound, + Message: fmt.Sprintf("Endpoint '%s' is not avalible", r.RequestURI), + } + })) + + return router +} diff --git a/server/internal/http/handler/ping.go b/server/internal/http/handler/ping.go new file mode 100644 index 0000000..b2e2018 --- /dev/null +++ b/server/internal/http/handler/ping.go @@ -0,0 +1,10 @@ +package handler + +import "net/http" + +func (h *Handler) Ping(w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(".")) + return nil +} diff --git a/server/internal/http/handler/websocket.go b/server/internal/http/handler/websocket.go new file mode 100644 index 0000000..9087a41 --- /dev/null +++ b/server/internal/http/handler/websocket.go @@ -0,0 +1,9 @@ +package handler + +import ( + "net/http" +) + +func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) error { + return h.manager.Upgrade(w, r) +} diff --git a/server/internal/http/middleware/logger.go b/server/internal/http/middleware/logger.go new file mode 100644 index 0000000..3d7211d --- /dev/null +++ b/server/internal/http/middleware/logger.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/middleware" + "github.com/rs/zerolog/log" +) + +func Logger(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + req := map[string]interface{}{} + + if reqID := middleware.GetReqID(r.Context()); reqID != "" { + req["id"] = reqID + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + req["scheme"] = scheme + req["proto"] = r.Proto + req["method"] = r.Method + req["remote"] = r.RemoteAddr + req["agent"] = r.UserAgent() + req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) + + fields := map[string]interface{}{} + fields["req"] = req + + entry := &entry{ + fields: fields, + } + + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + t1 := time.Now() + + defer func() { + entry.Write(ww.Status(), ww.BytesWritten(), time.Since(t1)) + }() + + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) +} + +type entry struct { + fields map[string]interface{} + errors []map[string]interface{} +} + +func (e *entry) Write(status, bytes int, elapsed time.Duration) { + res := map[string]interface{}{} + 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"] = "api" + + if len(e.errors) > 0 { + e.fields["errors"] = e.errors + log.Error().Fields(e.fields).Msgf("Request failed (%d)", status) + } else { + log.Debug().Fields(e.fields).Msgf("Request complete (%d)", status) + } +} + +func (e *entry) Panic(v interface{}, stack []byte) { + err := map[string]interface{}{} + err["message"] = fmt.Sprintf("%+v", v) + err["stack"] = string(stack) + + e.errors = append(e.errors, err) +} diff --git a/server/internal/http/middleware/middleware.go b/server/internal/http/middleware/middleware.go new file mode 100644 index 0000000..b151b9e --- /dev/null +++ b/server/internal/http/middleware/middleware.go @@ -0,0 +1,12 @@ +package middleware + +// contextKey is a value for use with context.WithValue. It's used as +// a pointer so it fits in an interface{} without allocation. This technique +// for defining context keys was copied from Go 1.7's new use of context in net/http. +type ctxKey struct { + name string +} + +func (k *ctxKey) String() string { + return "neko/ctx/" + k.name +} diff --git a/server/internal/http/middleware/recover.go b/server/internal/http/middleware/recover.go new file mode 100644 index 0000000..53f28e3 --- /dev/null +++ b/server/internal/http/middleware/recover.go @@ -0,0 +1,24 @@ +package middleware + +// The original work was derived from Goji's middleware, source: +// https://github.com/zenazn/goji/tree/master/web/middleware + +import ( + "net/http" + + "n.eko.moe/neko/internal/http/endpoint" +) + +func Recoverer(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rvr := recover(); rvr != nil { + endpoint.WriteError(w, r, rvr) + } + }() + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} diff --git a/server/internal/http/middleware/request.go b/server/internal/http/middleware/request.go new file mode 100644 index 0000000..00be35e --- /dev/null +++ b/server/internal/http/middleware/request.go @@ -0,0 +1,89 @@ +package middleware + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "os" + "strings" + "sync/atomic" +) + +// Key to use when setting the request ID. +type ctxKeyRequestID int + +// RequestIDKey is the key that holds the unique request ID in a request context. +const RequestIDKey ctxKeyRequestID = 0 + +var prefix string +var reqid uint64 + +// A quick note on the statistics here: we're trying to calculate the chance that +// two randomly generated base62 prefixes will collide. We use the formula from +// http://en.wikipedia.org/wiki/Birthday_problem +// +// P[m, n] \approx 1 - e^{-m^2/2n} +// +// We ballpark an upper bound for $m$ by imagining (for whatever reason) a server +// that restarts every second over 10 years, for $m = 86400 * 365 * 10 = 315360000$ +// +// For a $k$ character base-62 identifier, we have $n(k) = 62^k$ +// +// Plugging this in, we find $P[m, n(10)] \approx 5.75%$, which is good enough for +// our purposes, and is surely more than anyone would ever need in practice -- a +// process that is rebooted a handful of times a day for a hundred years has less +// than a millionth of a percent chance of generating two colliding IDs. + +func init() { + hostname, err := os.Hostname() + if hostname == "" || err != nil { + hostname = "localhost" + } + var buf [12]byte + var b64 string + for len(b64) < 10 { + rand.Read(buf[:]) + b64 = base64.StdEncoding.EncodeToString(buf[:]) + b64 = strings.NewReplacer("+", "", "/", "").Replace(b64) + } + + prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10]) +} + +// RequestID is a middleware that injects a request ID into the context of each +// request. A request ID is a string of the form "host.example.com/random-0001", +// where "random" is a base62 random string that uniquely identifies this go +// process, and where the last number is an atomically incremented request +// counter. +func RequestID(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + requestID := r.Header.Get("X-Request-Id") + if requestID == "" { + myid := atomic.AddUint64(&reqid, 1) + requestID = fmt.Sprintf("%s-%06d", prefix, myid) + } + ctx = context.WithValue(ctx, RequestIDKey, requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + } + return http.HandlerFunc(fn) +} + +// GetReqID returns a request ID from the given context if one is present. +// Returns the empty string if a request ID cannot be found. +func GetReqID(ctx context.Context) string { + if ctx == nil { + return "" + } + if reqID, ok := ctx.Value(RequestIDKey).(string); ok { + return reqID + } + return "" +} + +// NextRequestID generates the next request ID in the sequence. +func NextRequestID() uint64 { + return atomic.AddUint64(&reqid, 1) +} diff --git a/server/internal/http/response/response.go b/server/internal/http/response/response.go new file mode 100644 index 0000000..b051525 --- /dev/null +++ b/server/internal/http/response/response.go @@ -0,0 +1,32 @@ +package response + +import ( + "encoding/json" + "net/http" + + "n.eko.moe/neko/internal/http/endpoint" +) + +// JSON encodes data to rw in JSON format. Returns a pointer to a +// HandlerError if encoding fails. +func JSON(w http.ResponseWriter, data interface{}, status int) error { + w.WriteHeader(status) + w.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(w).Encode(data) + if err != nil { + return &endpoint.HandlerError{ + Status: http.StatusInternalServerError, + Message: "Unable to write JSON response", + Err: err, + } + } + + return nil +} + +// Empty merely sets the response code to NoContent (204). +func Empty(w http.ResponseWriter) error { + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/server/internal/keys/keyboard.go b/server/internal/keys/keyboard.go new file mode 100644 index 0000000..65556a2 --- /dev/null +++ b/server/internal/keys/keyboard.go @@ -0,0 +1,203 @@ +package keys + +const KEY_0 = 48 +const KEY_1 = 49 +const KEY_2 = 50 +const KEY_3 = 51 +const KEY_4 = 52 +const KEY_5 = 53 +const KEY_6 = 54 +const KEY_7 = 55 +const KEY_8 = 56 +const KEY_9 = 57 + +const KEY_A = 65 +const KEY_B = 66 +const KEY_C = 67 +const KEY_D = 68 +const KEY_E = 69 +const KEY_F = 70 +const KEY_G = 71 +const KEY_H = 72 +const KEY_I = 73 +const KEY_J = 74 +const KEY_K = 75 +const KEY_L = 76 +const KEY_M = 77 +const KEY_N = 78 +const KEY_O = 79 +const KEY_P = 80 +const KEY_Q = 81 +const KEY_R = 82 +const KEY_S = 83 +const KEY_T = 84 +const KEY_U = 85 +const KEY_V = 86 +const KEY_W = 87 +const KEY_X = 88 +const KEY_Y = 89 +const KEY_Z = 90 + +const KEY_NUMPAD0 = 96 +const KEY_NUMPAD1 = 97 +const KEY_NUMPAD2 = 98 +const KEY_NUMPAD3 = 99 +const KEY_NUMPAD4 = 100 +const KEY_NUMPAD5 = 101 +const KEY_NUMPAD6 = 102 +const KEY_NUMPAD7 = 103 +const KEY_NUMPAD8 = 104 +const KEY_NUMPAD9 = 105 + +const KEY_F1 = 112 +const KEY_F2 = 113 +const KEY_F3 = 114 +const KEY_F4 = 115 +const KEY_F5 = 116 +const KEY_F6 = 117 +const KEY_F7 = 118 +const KEY_F8 = 119 +const KEY_F9 = 120 +const KEY_F10 = 121 +const KEY_F11 = 122 +const KEY_F12 = 123 + +const KEY_BACK_SPACE = 8 +const KEY_TAB = 9 +const KEY_ENTER = 13 +const KEY_ENTER_ALT = 14 +const KEY_SHIFT = 16 +const KEY_CONTROL = 17 +const KEY_ALT = 18 +const KEY_ESCAPE = 27 +const KEY_SPACE = 32 +const KEY_PAGE_UP = 33 +const KEY_PAGE_DOWN = 34 +const KEY_END = 35 +const KEY_LEFT = 37 +const KEY_UP = 38 +const KEY_RIGHT = 39 +const KEY_DOWN = 40 +const KEY_DELETE = 46 +const KEY_SEMICOLON = 59 +const KEY_SEMICOLON_ALT = 186 +const KEY_EQUALS = 61 +const KEY_EQUALS_ALT = 187 +const KEY_MULTIPLY = 106 +const KEY_ADD = 107 +const KEY_SEPARATOR = 108 +const KEY_SUBTRACT = 109 +const KEY_SUBTRACT_ALT = 189 +const KEY_DECIMAL = 110 +const KEY_DIVIDE = 111 +const KEY_COMMA = 188 +const KEY_PERIOD = 190 +const KEY_SLASH = 191 +const KEY_BACK_QUOTE = 192 +const KEY_BACK_SLASH = 220 +const KEY_OPEN_BRACKET = 219 +const KEY_CLOSE_BRACKET = 221 +const KEY_QUOTE = 222 + +var Keyboard = map[int]string{} + +func init() { + Keyboard[KEY_A] = "a" + Keyboard[KEY_B] = "b" + Keyboard[KEY_C] = "c" + Keyboard[KEY_D] = "d" + Keyboard[KEY_E] = "e" + Keyboard[KEY_F] = "f" + Keyboard[KEY_G] = "g" + Keyboard[KEY_H] = "h" + Keyboard[KEY_I] = "i" + Keyboard[KEY_J] = "j" + Keyboard[KEY_K] = "k" + Keyboard[KEY_L] = "l" + Keyboard[KEY_M] = "m" + Keyboard[KEY_N] = "n" + Keyboard[KEY_O] = "o" + Keyboard[KEY_P] = "p" + Keyboard[KEY_Q] = "q" + Keyboard[KEY_R] = "r" + Keyboard[KEY_S] = "s" + Keyboard[KEY_T] = "r" + Keyboard[KEY_U] = "u" + Keyboard[KEY_V] = "v" + Keyboard[KEY_W] = "w" + Keyboard[KEY_X] = "x" + Keyboard[KEY_Y] = "y" + Keyboard[KEY_Z] = "z" + + Keyboard[KEY_0] = "0" + Keyboard[KEY_1] = "1" + Keyboard[KEY_2] = "2" + Keyboard[KEY_3] = "3" + Keyboard[KEY_4] = "4" + Keyboard[KEY_5] = "5" + Keyboard[KEY_6] = "6" + Keyboard[KEY_7] = "7" + Keyboard[KEY_8] = "8" + Keyboard[KEY_9] = "9" + + Keyboard[KEY_NUMPAD0] = "0" + Keyboard[KEY_NUMPAD1] = "1" + Keyboard[KEY_NUMPAD2] = "2" + Keyboard[KEY_NUMPAD3] = "3" + Keyboard[KEY_NUMPAD4] = "4" + Keyboard[KEY_NUMPAD5] = "5" + Keyboard[KEY_NUMPAD6] = "6" + Keyboard[KEY_NUMPAD7] = "7" + Keyboard[KEY_NUMPAD8] = "8" + Keyboard[KEY_NUMPAD9] = "9" + + Keyboard[KEY_F1] = "f1" + Keyboard[KEY_F2] = "f2" + Keyboard[KEY_F3] = "f3" + Keyboard[KEY_F4] = "f4" + Keyboard[KEY_F5] = "f5" + Keyboard[KEY_F6] = "f6" + Keyboard[KEY_F7] = "f7" + Keyboard[KEY_F8] = "f8" + Keyboard[KEY_F9] = "f9" + Keyboard[KEY_F10] = "f10" + Keyboard[KEY_F11] = "f11" + Keyboard[KEY_F12] = "f12" + + Keyboard[KEY_QUOTE] = "'" + Keyboard[KEY_COMMA] = "," + Keyboard[KEY_PERIOD] = "." + Keyboard[KEY_SEMICOLON] = ";" + Keyboard[KEY_SEMICOLON_ALT] = ";" + Keyboard[KEY_SLASH] = "/" + Keyboard[KEY_BACK_SLASH] = "\\" + Keyboard[KEY_BACK_QUOTE] = "`" + Keyboard[KEY_OPEN_BRACKET] = "[" + Keyboard[KEY_CLOSE_BRACKET] = "]" + Keyboard[KEY_EQUALS] = "=" + Keyboard[KEY_EQUALS_ALT] = "=" + Keyboard[KEY_MULTIPLY] = "*" + Keyboard[KEY_ADD] = "+" + Keyboard[KEY_SEPARATOR] = "." + Keyboard[KEY_SUBTRACT] = "-" + Keyboard[KEY_SUBTRACT_ALT] = "-" + Keyboard[KEY_DECIMAL] = "." + Keyboard[KEY_DIVIDE] = "/" + Keyboard[KEY_BACK_SPACE] = "backspace" + Keyboard[KEY_DELETE] = "delete" + Keyboard[KEY_ENTER] = "enter" + Keyboard[KEY_ENTER_ALT] = "enter" + Keyboard[KEY_TAB] = "tab" + Keyboard[KEY_ESCAPE] = "escape" + Keyboard[KEY_UP] = "up" + Keyboard[KEY_DOWN] = "down" + Keyboard[KEY_RIGHT] = "right" + Keyboard[KEY_LEFT] = "left" + Keyboard[KEY_END] = "end" + Keyboard[KEY_PAGE_UP] = "pageup" + Keyboard[KEY_PAGE_DOWN] = "pagedown" + Keyboard[KEY_ALT] = "alt" + Keyboard[KEY_CONTROL] = "control" + Keyboard[KEY_SHIFT] = "shift" + Keyboard[KEY_SPACE] = "space" +} diff --git a/server/internal/keys/mouse.go b/server/internal/keys/mouse.go new file mode 100644 index 0000000..9fec3bf --- /dev/null +++ b/server/internal/keys/mouse.go @@ -0,0 +1,21 @@ +package keys + +const MOUSE_LEFT = 0 +const MOUSE_MIDDLE = 1 +const MOUSE_RIGHT = 2 +const MOUSE_WHEEL_UP = 4 +const MOUSE_WHEEL_DOWN = 5 +const MOUSE_WHEEL_RIGH = 6 +const MOUSE_WHEEL_LEFT = 7 + +var Mouse = map[int]string{} + +func init() { + Mouse[MOUSE_LEFT] = "left" + Mouse[MOUSE_MIDDLE] = "center" + Mouse[MOUSE_RIGHT] = "right" + Mouse[MOUSE_WHEEL_UP] = "wheelUp" + Mouse[MOUSE_WHEEL_DOWN] = "wheelDown" + Mouse[MOUSE_WHEEL_RIGH] = "wheelRight" + Mouse[MOUSE_WHEEL_LEFT] = "wheelLeft" +} diff --git a/server/internal/nanoid/nanoid.go b/server/internal/nanoid/nanoid.go new file mode 100644 index 0000000..c7a742c --- /dev/null +++ b/server/internal/nanoid/nanoid.go @@ -0,0 +1,71 @@ +package nanoid + +import ( + "math/rand" + "time" + + gonanoid "github.com/matoous/go-nanoid" +) + +var nano *NanoID + +func init() { + nano = &NanoID{ + alphabet: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + size: 16, + } +} + +func New(alphabet string, size int) *NanoID { + return &NanoID{ + alphabet: alphabet, + size: size, + } +} + +type NanoID struct { + alphabet string + size int +} + +func (n *NanoID) NewID() (string, error) { + return gonanoid.Generate(n.alphabet, n.size) +} + +func (n *NanoID) NewIDSize(size int) (string, error) { + return gonanoid.Generate(n.alphabet, size) +} + +func (n *NanoID) NewIDRang(max int, min int) (string, error) { + rand.Seed(time.Now().Unix()) + return gonanoid.Generate(n.alphabet, rand.Intn(max-min)+min) +} + +func (n *NanoID) GenerateID(alphabet string, size int) (string, error) { + return gonanoid.Generate(alphabet, size) +} + +func (n *NanoID) GenerateIDRange(alphabet string, max int, min int) (string, error) { + rand.Seed(time.Now().Unix()) + return gonanoid.Generate(alphabet, rand.Intn(max-min)+min) +} + +func NewID() (string, error) { + return nano.NewID() +} + +func NewIDSize(size int) (string, error) { + return nano.NewIDSize(size) +} + +func NewIDRang(max int, min int) (string, error) { + return nano.NewIDRang(max, min) +} + +func GenerateID(alphabet string, size int) (string, error) { + return nano.GenerateID(alphabet, size) +} + +func GenerateIDRange(alphabet string, max int, min int) (string, error) { + return nano.GenerateIDRange(alphabet, max, min) +} diff --git a/server/internal/preflight/config.go b/server/internal/preflight/config.go new file mode 100644 index 0000000..e5426d6 --- /dev/null +++ b/server/internal/preflight/config.go @@ -0,0 +1,48 @@ +package preflight + +import ( + "runtime" + + "github.com/rs/zerolog/log" + "github.com/spf13/viper" +) + +func Config(name string) { + config := viper.GetString("neko.config") + + if config != "" { + viper.SetConfigFile(config) // Use config file from the flag. + } else { + if runtime.GOOS == "linux" { + viper.AddConfigPath("/etc/neko/") + } + + viper.AddConfigPath(".") + viper.SetConfigName(name) + } + + viper.SetEnvPrefix("NEKO") + 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) + } + } + + file := viper.ConfigFileUsed() + logger := log.With(). + Bool("debug", viper.GetBool("neko.debug")). + Str("logging", viper.GetString("neko.logs")). + Str("config", file). + Logger() + + if file == "" { + logger.Warn().Msg("Preflight complete without config file") + } else { + logger.Info().Msg("Preflight complete") + } +} diff --git a/server/internal/preflight/logs.go b/server/internal/preflight/logs.go new file mode 100644 index 0000000..e921ad3 --- /dev/null +++ b/server/internal/preflight/logs.go @@ -0,0 +1,60 @@ +package preflight + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/diode" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" +) + +func Logs(name string) { + zerolog.TimeFieldFormat = "" + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + if viper.GetBool("neko.debug") { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + + console := zerolog.ConsoleWriter{Out: os.Stdout} + + if !viper.GetBool("neko.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, name+"-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)) + } +} diff --git a/server/internal/structs/version.go b/server/internal/structs/version.go new file mode 100644 index 0000000..e9ef414 --- /dev/null +++ b/server/internal/structs/version.go @@ -0,0 +1,21 @@ +package structs + +import "fmt" + +type Version struct { + Major string + Minor string + Patch string + Version string + GitVersion string + GitCommit string + GitTreeState string + BuildDate string + GoVersion string + Compiler string + Platform string +} + +func (i *Version) String() string { + return fmt.Sprintf("%s.%s.%s", i.Major, i.Minor, i.Patch) +} diff --git a/server/internal/utils/color.go b/server/internal/utils/color.go new file mode 100644 index 0000000..919887c --- /dev/null +++ b/server/internal/utils/color.go @@ -0,0 +1,34 @@ +package utils + +import ( + "fmt" + "regexp" +) + +const ( + char = "&" +) + +// Colors: http://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html +var re = regexp.MustCompile(char + `(?m)([0-9]{1,2};[0-9]{1,2}|[0-9]{1,2})`) + +func Color(str string) string { + result := "" + lastIndex := 0 + + for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { + groups := []string{} + for i := 0; i < len(v); i += 2 { + groups = append(groups, str[v[i]:v[i+1]]) + } + + result += str[lastIndex:v[0]] + "\033[" + groups[1] + "m" + lastIndex = v[1] + } + + return result + str[lastIndex:] +} + +func Colorf(format string, a ...interface{}) string { + return fmt.Sprintf(Color(format), a...) +} diff --git a/server/internal/utils/header.go b/server/internal/utils/header.go new file mode 100644 index 0000000..7c4646a --- /dev/null +++ b/server/internal/utils/header.go @@ -0,0 +1,10 @@ +package utils + +const Header = `&34 + _ __ __ + / | / /__ / /______ \ /\ + / |/ / _ \/ //_/ __ \ ) ( ') + / /| / __/ ,< / /_/ / ( / ) +/_/ |_/\___/_/|_|\____/ \(__)| +&1&37 nurdism/neko &33%s v%s&0 +` diff --git a/server/internal/utils/map.go b/server/internal/utils/map.go new file mode 100644 index 0000000..a1db944 --- /dev/null +++ b/server/internal/utils/map.go @@ -0,0 +1,25 @@ +package utils + +import ( + "sync" + "sync/atomic" +) + +type CountedSyncMap struct { + sync.Map + len uint64 +} + +func (m *CountedSyncMap) CountedDelete(key interface{}) { + m.Delete(key) + atomic.AddUint64(&m.len, ^uint64(0)) +} + +func (m *CountedSyncMap) CountedStore(key, value interface{}) { + m.Store(key, value) + atomic.AddUint64(&m.len, uint64(1)) +} + +func (m *CountedSyncMap) CountedLen() uint64 { + return atomic.LoadUint64(&m.len) +} diff --git a/server/internal/webrtc/data.go b/server/internal/webrtc/data.go new file mode 100644 index 0000000..177ef6a --- /dev/null +++ b/server/internal/webrtc/data.go @@ -0,0 +1,22 @@ +package webrtc + +type dataHeader struct { + Event uint8 + Length uint16 +} + +type dataMouseMove struct { + dataHeader + X int16 + Y int16 +} + +type dataMouseKey struct { + dataHeader + Key uint8 +} + +type dataKeyboardKey struct { + dataHeader + Key uint16 +} diff --git a/server/internal/webrtc/manager.go b/server/internal/webrtc/manager.go new file mode 100644 index 0000000..777241d --- /dev/null +++ b/server/internal/webrtc/manager.go @@ -0,0 +1,73 @@ +package webrtc + +import ( + "math/rand" + "net/http" + + "github.com/gorilla/websocket" + "github.com/pion/webrtc/v2" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "n.eko.moe/neko/internal/gst" +) + +func NewManager(password string) (*WebRTCManager, error) { + engine := webrtc.MediaEngine{} + + videoCodec := webrtc.NewRTPVP8Codec(webrtc.DefaultPayloadTypeVP8, 90000) + video, err := webrtc.NewTrack(webrtc.DefaultPayloadTypeVP8, rand.Uint32(), "stream", "stream", videoCodec) + if err != nil { + return nil, err + } + gst.CreatePipeline(webrtc.VP8, []*webrtc.Track{video}, "ximagesrc show-pointer=true use-damage=false ! video/x-raw,framerate=30/1 ! videoconvert").Start() + engine.RegisterCodec(videoCodec) + // ximagesrc xid=0 show-pointer=true ! videoconvert ! queue | videotestsrc + + audioCodec := webrtc.NewRTPOpusCodec(webrtc.DefaultPayloadTypeOpus, 48000) + audio, err := webrtc.NewTrack(webrtc.DefaultPayloadTypeOpus, rand.Uint32(), "stream", "stream", audioCodec) + if err != nil { + return nil, err + } + gst.CreatePipeline(webrtc.Opus, []*webrtc.Track{audio}, "pulsesrc device=auto_null.monitor ! audioconvert").Start() + engine.RegisterCodec(audioCodec) + // pulsesrc device=auto_null.monitor ! audioconvert | audiotestsrc + // gst-launch-1.0 -v pulsesrc device=auto_null.monitor ! audioconvert ! vorbisenc ! oggmux ! filesink location=alsasrc.ogg + + return &WebRTCManager{ + logger: log.With().Str("service", "webrtc").Logger(), + engine: engine, + api: webrtc.NewAPI(webrtc.WithMediaEngine(engine)), + video: video, + audio: audio, + controller: "", + password: password, + sessions: make(map[string]*session), + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + config: webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{ + { + URLs: []string{"stun:stun.l.google.com:19302"}, + }, + }, + SDPSemantics: webrtc.SDPSemanticsUnifiedPlanWithFallback, + }, + }, nil +} + +type WebRTCManager struct { + logger zerolog.Logger + upgrader websocket.Upgrader + engine webrtc.MediaEngine + api *webrtc.API + config webrtc.Configuration + password string + controller string + sessions map[string]*session + video *webrtc.Track + audio *webrtc.Track +} diff --git a/server/internal/webrtc/messages.go b/server/internal/webrtc/messages.go new file mode 100644 index 0000000..7cd948d --- /dev/null +++ b/server/internal/webrtc/messages.go @@ -0,0 +1,15 @@ +package webrtc + +type message struct { + Event string `json:"event"` +} + +type messageIdentityProvide struct { + message + ID string `json:"id"` +} + +type messageSDP struct { + message + SDP string `json:"sdp"` +} diff --git a/server/internal/webrtc/peer.go b/server/internal/webrtc/peer.go new file mode 100644 index 0000000..b1172c6 --- /dev/null +++ b/server/internal/webrtc/peer.go @@ -0,0 +1,220 @@ +package webrtc + +import ( + "bytes" + "encoding/binary" + "encoding/json" + + "github.com/go-vgo/robotgo" + "github.com/pion/webrtc/v2" + + "n.eko.moe/neko/internal/keys" +) + +func (manager *WebRTCManager) createPeer(session *session, raw []byte) error { + payload := messageSDP{} + if err := json.Unmarshal(raw, &payload); err != nil { + return err + } + + peer, err := manager.api.NewPeerConnection(manager.config) + if err != nil { + return err + } + + _, err = peer.AddTrack(manager.video) + if err != nil { + return err + } + + _, err = peer.AddTrack(manager.audio) + if err != nil { + return err + } + + peer.SetRemoteDescription(webrtc.SessionDescription{ + SDP: payload.SDP, + Type: webrtc.SDPTypeOffer, + }) + + answer, err := peer.CreateAnswer(nil) + if err != nil { + return err + } + + if err = peer.SetLocalDescription(answer); err != nil { + return err + } + + session.send(messageSDP{ + message{Event: "sdp/reply"}, + answer.SDP, + }) + + session.peer = peer + + peer.OnDataChannel(func(d *webrtc.DataChannel) { + d.OnMessage(func(msg webrtc.DataChannelMessage) { + if err = manager.onData(session, msg); err != nil { + manager.logger.Warn().Err(err).Msg("onData failed") + } + }) + }) + + peer.OnConnectionStateChange(func(connectionState webrtc.PeerConnectionState) { + switch connectionState { + case webrtc.PeerConnectionStateDisconnected: + case webrtc.PeerConnectionStateFailed: + manager.destroy(session) + break + case webrtc.PeerConnectionStateConnected: + manager.logger.Info().Str("ID", session.id).Msg("Peer connected") + break + } + }) + + return nil +} + +var debounce = map[int]bool{} + +func (manager *WebRTCManager) onData(session *session, msg webrtc.DataChannelMessage) error { + if manager.controller != session.id { + return nil + } + + header := &dataHeader{} + buffer := bytes.NewBuffer(msg.Data) + byt := make([]byte, 3) + _, err := buffer.Read(byt) + if err != nil { + return err + } + + err = binary.Read(bytes.NewBuffer(byt), binary.LittleEndian, header) + if err != nil { + return err + } + + buffer = bytes.NewBuffer(msg.Data) + + switch header.Event { + case 0x01: // MOUSE_MOVE + payload := &dataMouseMove{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + robotgo.Move(int(payload.X), int(payload.Y)) + break + case 0x02: // MOUSE_UP + payload := &dataMouseKey{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + + if key, ok := keys.Mouse[int(payload.Key)]; ok { + if !debounce[int(payload.Key)] { + return nil + } + debounce[int(payload.Key)] = false + robotgo.MouseToggle("up", key) + } else { + manager.logger.Warn().Msgf("Unknown MOUSE_DOWN key: %v", payload.Key) + } + break + case 0x03: // MOUSE_DOWN + payload := &dataMouseKey{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + + if key, ok := keys.Mouse[int(payload.Key)]; ok { + if debounce[int(payload.Key)] { + return nil + } + debounce[int(payload.Key)] = true + + robotgo.MouseToggle("down", key) + } else { + manager.logger.Warn().Msgf("Unknown MOUSE_DOWN key: %v", payload.Key) + } + break + case 0x04: // MOUSE_CLK + payload := &dataMouseKey{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + + if key, ok := keys.Mouse[int(payload.Key)]; ok { + switch int(payload.Key) { + case keys.MOUSE_WHEEL_DOWN: + robotgo.Scroll(0, -10) + break + case keys.MOUSE_WHEEL_UP: + robotgo.Scroll(0, 10) + break + case keys.MOUSE_WHEEL_LEFT: + robotgo.Scroll(-10, 0) + break + case keys.MOUSE_WHEEL_RIGH: + robotgo.Scroll(10, 0) + break + default: + robotgo.Click(key, false) + } + } else { + manager.logger.Warn().Msgf("Unknown MOUSE_CLK key: %v", payload.Key) + } + break + case 0x05: // KEY_DOWN + payload := &dataKeyboardKey{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + if key, ok := keys.Keyboard[int(payload.Key)]; ok { + if debounce[int(payload.Key)] { + return nil + } + debounce[int(payload.Key)] = true + robotgo.KeyToggle(key, "down") + } else { + manager.logger.Warn().Msgf("Unknown KEY_DOWN key: %v", payload.Key) + } + break + case 0x06: // KEY_UP + payload := &dataKeyboardKey{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + if key, ok := keys.Keyboard[int(payload.Key)]; ok { + if !debounce[int(payload.Key)] { + return nil + } + debounce[int(payload.Key)] = false + robotgo.KeyToggle(key, "up") + } else { + manager.logger.Warn().Msgf("Unknown KEY_UP key: %v", payload.Key) + } + break + case 0x07: // KEY_CLK + payload := &dataKeyboardKey{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + if key, ok := keys.Keyboard[int(payload.Key)]; ok { + robotgo.KeyTap(key) + } else { + manager.logger.Warn().Msgf("Unknown KEY_CLK key: %v", payload.Key) + } + break + } + + return nil +} diff --git a/server/internal/webrtc/session.go b/server/internal/webrtc/session.go new file mode 100644 index 0000000..11504a9 --- /dev/null +++ b/server/internal/webrtc/session.go @@ -0,0 +1,41 @@ +package webrtc + +import ( + "sync" + + "github.com/gorilla/websocket" + "github.com/pion/webrtc/v2" +) + +type session struct { + id string + socket *websocket.Conn + peer *webrtc.PeerConnection + mu sync.Mutex +} + +func (session *session) send(v interface{}) error { + session.mu.Lock() + defer session.mu.Unlock() + + if session.socket != nil { + return session.socket.WriteJSON(v) + } + + return nil +} + +func (session *session) destroy() error { + if session.peer != nil && session.peer.ConnectionState() == webrtc.PeerConnectionStateConnected { + if err := session.peer.Close(); err != nil { + return err + } + } + + if session.socket != nil { + if err := session.socket.Close(); err != nil { + return err + } + } + return nil +} diff --git a/server/internal/webrtc/websocket.go b/server/internal/webrtc/websocket.go new file mode 100644 index 0000000..7677715 --- /dev/null +++ b/server/internal/webrtc/websocket.go @@ -0,0 +1,222 @@ +package webrtc + +import ( + "encoding/json" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/pkg/errors" + + "n.eko.moe/neko/internal/nanoid" +) + +const ( + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = 60 * time.Second +) + +func (manager *WebRTCManager) Upgrade(w http.ResponseWriter, r *http.Request) error { + socket, err := manager.upgrader.Upgrade(w, r, nil) + if err != nil { + manager.logger.Error().Err(err).Msg("Failed to upgrade websocket!") + return nil + } + + sessionID, ok := manager.authenticate(r) + if ok != true { + manager.logger.Warn().Msg("Authenticatetion failed") + if err = socket.Close(); err != nil { + return err + } + return nil + } + + session := &session{ + id: sessionID, + socket: socket, + mu: sync.Mutex{}, + } + + manager.logger. + Info(). + Str("ID", sessionID). + Str("RemoteAddr", socket.RemoteAddr().String()). + Msg("Created Session") + + manager.sessions[sessionID] = session + + defer func() { + manager.destroy(session) + }() + + if err = manager.onConnected(session); err != nil { + manager.logger.Error().Err(err).Msg("onConnected failed!") + return nil + } + + manager.handleWS(session) + + return nil +} + +func (manager *WebRTCManager) authenticate(r *http.Request) (sessionID string, ok bool) { + + passwords, ok := r.URL.Query()["password"] + if !ok || len(passwords[0]) < 1 { + return "", false + } + + if passwords[0] != manager.password { + manager.logger.Warn().Str("Password", passwords[0]).Msg("Wrong password: ") + return "", false + } + + id, err := nanoid.NewIDSize(32) + if err != nil { + return "", false + } + return id, true +} + +func (manager *WebRTCManager) onConnected(session *session) error { + if err := session.send(messageIdentityProvide{ + message: message{Event: "identity/provide"}, + ID: session.id, + }); err != nil { + return err + } + return nil +} + +func (manager *WebRTCManager) onMessage(session *session, raw []byte) error { + message := message{} + if err := json.Unmarshal(raw, &message); err != nil { + return err + } + + switch message.Event { + case "sdp/provide": + return errors.Wrap(manager.createPeer(session, raw), "sdp/provide failed") + case "control/release": + return errors.Wrap(manager.controlRelease(session), "control/release failed") + case "control/request": + return errors.Wrap(manager.controlRequest(session), "control/request failed") + default: + manager.logger.Warn().Msgf("Unknown client method %s", message.Event) + } + + return nil +} + +func (manager *WebRTCManager) handleWS(session *session) { + bytes := make(chan []byte) + cancel := make(chan struct{}) + ticker := time.NewTicker(pingPeriod) + + go func() { + defer func() { + ticker.Stop() + manager.logger.Info().Str("RemoteAddr", session.socket.RemoteAddr().String()).Msg("Handle WS ending") + manager.destroy(session) + }() + + for { + _, raw, err := session.socket.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + manager.logger.Warn().Err(err).Msg("ReadMessage error") + } + break + } + bytes <- raw + } + }() + + for { + select { + case raw := <-bytes: + manager.logger.Info(). + Str("ID", session.id). + Str("Message", string(raw)). + Msg("Reading from Websocket") + if err := manager.onMessage(session, raw); err != nil { + manager.logger.Error().Err(err).Msg("onClientMessage has failed") + return + } + case <-cancel: + return + case _ = <-ticker.C: + if err := session.socket.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func (manager *WebRTCManager) destroy(session *session) { + if manager.controller == session.id { + manager.controller = "" + for id, sess := range manager.sessions { + if id != session.id { + if err := sess.send(message{Event: "control/released"}); err != nil { + manager.logger.Error().Err(err).Msg("session.send has failed") + } + } + } + } + + if err := session.destroy(); err != nil { + manager.logger.Error().Err(err).Msg("session.destroy has failed") + } + + delete(manager.sessions, session.id) +} + +func (manager *WebRTCManager) controlRelease(session *session) error { + if manager.controller == session.id { + manager.controller = "" + + if err := session.send(message{Event: "control/release"}); err != nil { + return err + } + + for id, sess := range manager.sessions { + if id != session.id { + if err := sess.send(message{Event: "control/released"}); err != nil { + return err + } + } + } + } + return nil +} + +func (manager *WebRTCManager) controlRequest(session *session) error { + if manager.controller == "" { + manager.controller = session.id + + if err := session.send(message{Event: "control/give"}); err != nil { + return err + } + + for id, sess := range manager.sessions { + if id != session.id { + if err := sess.send(message{Event: "control/given"}); err != nil { + return err + } + } + } + } else { + if err := session.send(message{Event: "control/locked"}); err != nil { + return err + } + + controller, ok := manager.sessions[manager.controller] + if ok { + controller.send(message{Event: "control/requesting"}) + } + } + return nil +} diff --git a/server/neko.go b/server/neko.go new file mode 100644 index 0000000..a870f01 --- /dev/null +++ b/server/neko.go @@ -0,0 +1,116 @@ +package neko + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "runtime" + + "n.eko.moe/neko/internal/config" + "n.eko.moe/neko/internal/structs" + api "n.eko.moe/neko/internal/http" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var ( + // + buildDate = "" + // + gitCommit = "" + // + gitVersion = "" + // + gitState = "" + // Major version when you make incompatible API changes, + major = "0" + // Minor version when you add functionality in a backwards-compatible manner, and + minor = "0" + // Patch version when you make backwards-compatible bug fixeneko. + patch = "0" +) + +var Service *Neko + +func init() { + Service = &Neko{ + Version: &structs.Version{ + Major: major, + Minor: minor, + Patch: patch, + GitVersion: gitVersion, + GitCommit: gitCommit, + GitTreeState: gitState, + BuildDate: buildDate, + GoVersion: runtime.Version(), + Compiler: runtime.Compiler, + Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + }, + Root: &config.Root{}, + Serve: &config.Serve{}, + } +} + +type Neko struct { + Version *structs.Version + Root *config.Root + Serve *config.Serve + Logger zerolog.Logger + http *http.Server +} + +func (neko *Neko) Preflight() { + neko.Logger = log.With().Str("service", "neko").Logger() +} + +func (neko *Neko) Start() { + server := api.New(neko.Serve.Bind, neko.Serve.Password, neko.Serve.Static) + + if neko.Serve.Cert != "" && neko.Serve.Key != "" { + go func() { + if err := server.ListenAndServeTLS(neko.Serve.Cert, neko.Serve.Key); err != http.ErrServerClosed { + neko.Logger.Panic().Err(err).Msg("Unable to start https server") + } + }() + neko.Logger.Info().Msgf("HTTPS listening on %s", server.Addr) + } else { + go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + neko.Logger.Panic().Err(err).Msg("Unable to start http server") + } + }() + neko.Logger.Warn().Msgf("HTTP listening on %s", server.Addr) + } + + neko.http = server +} + +func (neko *Neko) Shutdown() { + if neko.http != nil { + if err := neko.http.Shutdown(context.Background()); err != nil { + neko.Logger.Err(err).Msg("HTTP server shutdown with an error") + } else { + neko.Logger.Debug().Msg("HTTP server shutdown") + } + } +} + +func (neko *Neko) ServeCommand(cmd *cobra.Command, args []string) { + neko.Logger.Info().Msg("Starting HTTP/S server") + neko.Start() + + neko.Logger.Info().Msg("Service ready") + + quit := make(chan os.Signal) + signal.Notify(quit, os.Interrupt) + sig := <-quit + + neko.Logger.Warn().Msgf("Received %s, attempting graceful shutdown: \n", sig) + + neko.Shutdown() + neko.Logger.Info().Msg("Shutting down complete") +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..068ddcd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./client/tsconfig.json" +} \ No newline at end of file