├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── client_build.yml │ └── client_test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── OpenApi.yml ├── README.md ├── client ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── api │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── .openapi-generator-ignore │ │ ├── .openapi-generator │ │ │ ├── FILES │ │ │ └── VERSION │ │ ├── api.ts │ │ ├── base.ts │ │ ├── common.ts │ │ ├── configuration.ts │ │ └── index.ts │ ├── assets │ │ └── styles │ │ │ ├── _variables.scss │ │ │ ├── main.scss │ │ │ └── vendor │ │ │ └── _sweetalert2.scss │ ├── components │ │ ├── Pull.vue │ │ ├── RoomActionBtn.vue │ │ ├── RoomInfo.vue │ │ ├── RoomLink.vue │ │ ├── RoomsCreate.vue │ │ ├── RoomsList.vue │ │ └── RoomsQuick.vue │ ├── main.ts │ ├── plugins │ │ ├── filters.ts │ │ ├── sweetalert.ts │ │ └── vuetify.ts │ ├── shims-tsx.d.ts │ ├── shims-vue.d.ts │ ├── shims-vuetify.d.ts │ ├── store │ │ ├── index.ts │ │ └── state.ts │ ├── utils │ │ └── random.ts │ └── views │ │ └── Home.vue ├── tsconfig.json └── vue.config.js ├── cmd ├── neko_rooms │ └── main.go ├── root.go └── serve.go ├── community ├── README.md └── scripts │ ├── aws-linux.sh │ └── ubuntu-debian.sh ├── dev ├── .gitignore ├── api-gen ├── build ├── go ├── rebuild ├── serve ├── start └── traefik │ ├── .env.example │ ├── rebuild │ ├── serve │ ├── start │ └── traefik ├── docker-compose.yml ├── docs ├── README.md ├── architecture.drawio ├── architecture.svg ├── dind │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yml │ ├── nrooms-entrypoint.sh │ └── nrooms-start.sh ├── lables.md ├── neko.gif ├── new_room.png ├── rooms.png ├── storage.md └── storage.png ├── go.mod ├── go.sum ├── internal ├── api │ ├── api.go │ ├── config.go │ ├── events.go │ ├── pull.go │ └── rooms.go ├── config │ ├── config.go │ ├── room.go │ ├── root.go │ └── server.go ├── policies │ ├── chromium │ │ ├── generator.go │ │ ├── parser.go │ │ └── policies.json │ ├── config.go │ └── firefox │ │ ├── generator.go │ │ ├── parser.go │ │ └── policies.json ├── proxy │ ├── lobby.go │ └── manager.go ├── pull │ └── manager.go ├── room │ ├── containers.go │ ├── events.go │ ├── labels.go │ ├── manager.go │ └── ports.go ├── server │ ├── logger.go │ └── manager.go ├── types │ ├── api.go │ ├── policies.go │ ├── proxy.go │ ├── pull.go │ ├── room.go │ ├── room_api_v2.go │ └── room_api_v3.go └── utils │ ├── color.go │ ├── fs.go │ ├── swal2.go │ ├── swal2.html │ └── uid.go ├── neko.go ├── pkg └── prefix │ ├── doc.go │ ├── tree.go │ └── tree_test.go ├── traefik ├── .env.example ├── .gitignore ├── README.md ├── config │ ├── middlewares.yml │ ├── routers.yml │ └── tls.yml ├── docker-compose.http.yml ├── docker-compose.yml ├── install ├── nginx-auth │ ├── README.md │ ├── docker-compose.yml │ └── nginx.conf ├── nginx │ ├── docker-compose.yml │ └── nginx.conf └── traefik.yml └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | dev 2 | docs 3 | client/node_modules 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ m1k1o ] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "CI for builds" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | build-client: 12 | name: Build Client Artifacts 13 | uses: ./.github/workflows/client_build.yml 14 | 15 | build-and-push: 16 | name: Build and Push Docker Image 17 | runs-on: ubuntu-latest 18 | needs: build-client 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Download client dist 26 | uses: actions/download-artifact@v4 27 | with: 28 | name: client 29 | path: client/dist 30 | 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v3 33 | 34 | - name: Set up Docker Buildx 35 | uses: docker/setup-buildx-action@v3 36 | 37 | - name: Extract metadata (tags, labels) for Docker 38 | uses: docker/metadata-action@v5 39 | id: meta 40 | with: 41 | images: | 42 | docker.io/${{ github.repository }} 43 | ghcr.io/${{ github.repository }} 44 | tags: | 45 | type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} 46 | type=semver,pattern={{version}} 47 | type=semver,pattern={{major}}.{{minor}} 48 | type=semver,pattern={{major}} 49 | 50 | - name: Log in to Docker Hub 51 | uses: docker/login-action@v3 52 | with: 53 | username: ${{ github.actor }} 54 | password: ${{ secrets.DOCKER_TOKEN }} 55 | 56 | - name: Log in to GitHub Container Registry 57 | uses: docker/login-action@v3 58 | with: 59 | registry: ghcr.io 60 | username: ${{ github.actor }} 61 | password: ${{ secrets.GHCR_ACCESS_TOKEN }} 62 | 63 | - name: Remove client stage from Dockerfile 64 | # Change dockerfile: remove first stage - everything between # STAGE 1 and # STAGE 2 65 | # Replace "--from=frontend /src/dist/" with "./client/dist/" 66 | run: | 67 | sed -i '/# STAGE 1/,/# STAGE 2/d' ./Dockerfile 68 | sed -i 's/--from=frontend \/src\/dist\//.\/client\/dist\//g' ./Dockerfile 69 | 70 | - name: Build and push 71 | uses: docker/build-push-action@v6 72 | with: 73 | context: ./ 74 | push: true 75 | tags: ${{ steps.meta.outputs.tags }} 76 | labels: ${{ steps.meta.outputs.labels }} 77 | platforms: linux/amd64,linux/arm64,linux/arm/v7 78 | cache-from: type=gha 79 | cache-to: type=gha,mode=max 80 | -------------------------------------------------------------------------------- /.github/workflows/client_build.yml: -------------------------------------------------------------------------------- 1 | name: Build Client 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | with-artifact: 7 | required: false 8 | type: boolean 9 | default: true 10 | description: | 11 | If true, the build artifacts will be uploaded as a GitHub Actions artifact. 12 | This is useful for debugging and testing purposes. If false, the artifacts 13 | will not be uploaded. This is useful for test builds where you don't need 14 | the artifacts. 15 | 16 | jobs: 17 | build-client: 18 | name: Build Client 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: npm 30 | cache-dependency-path: client/package-lock.json 31 | 32 | - name: Install dependencies 33 | working-directory: ./client 34 | run: npm ci 35 | 36 | - name: Build client 37 | working-directory: ./client 38 | run: npm run build 39 | 40 | - name: Upload artifacts 41 | uses: actions/upload-artifact@v4 42 | if: ${{ inputs.with-artifact }} 43 | with: 44 | name: client 45 | path: client/dist 46 | -------------------------------------------------------------------------------- /.github/workflows/client_test.yml: -------------------------------------------------------------------------------- 1 | name: Test Client 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | paths: 8 | - client/** 9 | - .github/workflows/client_build.yml 10 | - .github/workflows/client_test.yml 11 | 12 | jobs: 13 | test-client: 14 | name: Test Client 15 | uses: ./.github/workflows/client_build.yml 16 | with: 17 | # Do not upload artifacts for test builds 18 | with-artifact: false 19 | secrets: inherit 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | bin 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # STAGE 1: build static web files 3 | # 4 | FROM node:20-bookworm-slim AS frontend 5 | WORKDIR /src 6 | 7 | # 8 | # install dependencies 9 | COPY client/package*.json client/.npmrc ./ 10 | RUN npm install 11 | 12 | # 13 | # build client 14 | COPY client/ . 15 | RUN npm run build 16 | 17 | # 18 | # STAGE 2: build executable binary 19 | # 20 | FROM golang:1.21-bullseye AS builder 21 | WORKDIR /app 22 | 23 | COPY . . 24 | RUN go get -v -t -d .; \ 25 | CGO_ENABLED=0 go build -o bin/neko_rooms cmd/neko_rooms/main.go 26 | 27 | # 28 | # STAGE 3: build a small image 29 | # 30 | FROM scratch 31 | COPY --from=builder /app/bin/neko_rooms /app/bin/neko_rooms 32 | COPY --from=frontend /src/dist/ /var/www 33 | 34 | ENV DOCKER_API_VERSION=1.39 35 | ENV NEKO_ROOMS_BIND=:8080 36 | ENV NEKO_ROOMS_ADMIN_STATIC=/var/www 37 | 38 | EXPOSE 8080 39 | 40 | ENTRYPOINT [ "/app/bin/neko_rooms" ] 41 | CMD [ "serve" ] 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neko-rooms 2 | 3 |

4 | release 5 | license 6 | pulls 7 | issues 8 | Chat on discord 9 |

10 | 11 | Simple room management system for [n.eko](https://github.com/m1k1o/neko). Self hosted rabb.it alternative. 12 | 13 |
14 | rooms 15 | new room 16 | n.eko 17 |
18 | 19 | ## Zero-knowledge installation (with HTTPS) 20 | 21 | No experience with Docker and reverse proxy? No problem! Follow these steps to set up your Neko Rooms quickly and securely: 22 | 23 | - Rent a VPS with public IP and OS Ubuntu. 24 | - Get a domain name pointing to your IP (you can even get some for free). 25 | - Run install script and follow instructions. 26 | - Secure using HTTPs thanks to Let's Encrypt and Traefik or NGINX. 27 | 28 | ```bash 29 | wget -O neko-rooms-traefik.sh https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/install 30 | sudo bash neko-rooms-traefik.sh 31 | ``` 32 | 33 | ### Community Installation Scripts 34 | 35 | We have community-contributed installation scripts available. Check out our [community installation guides](./community/README.md) for instructions on installing neko-rooms on various Linux distributions. These scripts are maintained by the community and support different Linux distributions like Arch Linux, Fedora, and more. 36 | 37 | ## How to start 38 | 39 | If you want to use Traefik as reverse proxy, visit [installation guide for traefik as reverse proxy](./traefik/). 40 | 41 | Otherwise modify variables in `docker-compose.yml` and just run `docker-compose up -d`. 42 | 43 | ### Download images / update 44 | 45 | You need to pull all your images, that you want to use with neko-room. Otherwise, you might get this error: `Error response from daemon: No such image:` (see issue #1). 46 | 47 | ```sh 48 | docker pull ghcr.io/m1k1o/neko/firefox 49 | docker pull ghcr.io/m1k1o/neko/chromium 50 | # etc... 51 | ``` 52 | 53 | If you want to update neko image, you need to pull new image and recreate all rooms, that use old image. To update neko rooms, simply run: 54 | 55 | ```sh 56 | docker-compose pull 57 | docker-compose up -d 58 | ``` 59 | 60 | ### Enable storage 61 | 62 | You might have encountered this error: 63 | 64 | > Mounts cannot be specified because storage is disabled or unavailable. 65 | 66 | If you didn't specify storage yet, you can do it using [this tutorial](./docs/storage.md). 67 | 68 | ### Use nvidia GPU 69 | 70 | If you want to use nvidia GPU, you need to install [nvidia-docker](https://github.com/NVIDIA/nvidia-docker). 71 | 72 | Change neko images to nvidia images in `docker-compose.yml` using envorinment variable `NEKO_ROOMS_NEKO_IMAGES`: 73 | 74 | ```bash 75 | NEKO_ROOMS_NEKO_IMAGES=" 76 | ghcr.io/m1k1o/neko/nvidia-chromium:latest 77 | ghcr.io/m1k1o/neko/nvidia-google-chrome:latest 78 | ghcr.io/m1k1o/neko/nvidia-microsoft-edge:latest 79 | ghcr.io/m1k1o/neko/nvidia-brave:latest 80 | " 81 | ``` 82 | 83 | When creating new room, you need to specify to use GPU in expext settings. 84 | 85 | ### Docs 86 | 87 | For more information visit [docs](./docs). 88 | 89 | ### Roadmap: 90 | - [x] add GUI 91 | - [x] add HTTPS support 92 | - [x] add authentication provider for traefik 93 | - [x] allow specifying custom ENV variables 94 | - [x] allow mounting directories for persistent data 95 | - [x] optionally remove Traefik as dependency 96 | - [ ] add upgrade button 97 | - [ ] auto pull images, that do not exist 98 | - [ ] add bearer token to for API 99 | - [ ] add docker SSH / TCP support 100 | - [ ] add docker swarm support 101 | - [ ] add k8s support 102 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /client/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^1.5.1", 12 | "core-js": "^3.8.3", 13 | "moment": "^2.29.4", 14 | "sweetalert2": "^11.7.31", 15 | "vue": "^2.6.14", 16 | "vue-class-component": "^7.2.3", 17 | "vue-property-decorator": "^9.1.2", 18 | "vuetify": "^2.6.0", 19 | "vuex": "^3.6.2" 20 | }, 21 | "devDependencies": { 22 | "@typescript-eslint/eslint-plugin": "^5.4.0", 23 | "@typescript-eslint/parser": "^5.4.0", 24 | "@vue/cli-plugin-babel": "~5.0.0", 25 | "@vue/cli-plugin-eslint": "~5.0.0", 26 | "@vue/cli-plugin-router": "~5.0.0", 27 | "@vue/cli-plugin-typescript": "~5.0.0", 28 | "@vue/cli-plugin-vuex": "~5.0.0", 29 | "@vue/cli-service": "~5.0.0", 30 | "@vue/eslint-config-standard": "^6.1.0", 31 | "@vue/eslint-config-typescript": "^9.1.0", 32 | "eslint": "^7.32.0", 33 | "eslint-plugin-import": "^2.25.3", 34 | "eslint-plugin-node": "^11.1.0", 35 | "eslint-plugin-promise": "^5.1.0", 36 | "eslint-plugin-vue": "^8.0.3", 37 | "sass": "^1.32.7", 38 | "sass-loader": "^12.0.0", 39 | "typescript": "~4.5.5", 40 | "vue-cli-plugin-vuetify": "~2.5.8", 41 | "vue-template-compiler": "^2.6.14", 42 | "vuetify-loader": "^1.7.0", 43 | "webpack": "^5.88.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1k1o/neko-rooms/da890b40fc32cb8565b8fdce96e4571b33cd739f/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1k1o/neko-rooms/da890b40fc32cb8565b8fdce96e4571b33cd739f/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1k1o/neko-rooms/da890b40fc32cb8565b8fdce96e4571b33cd739f/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | neko-rooms 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 |
19 |

20 | Selfhosted collaborative browser (m1k1o/neko-rooms) - room management for n.eko 21 |

22 | 23 | 24 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 53 | -------------------------------------------------------------------------------- /client/src/api/.gitignore: -------------------------------------------------------------------------------- 1 | wwwroot/*.js 2 | node_modules 3 | typings 4 | dist 5 | -------------------------------------------------------------------------------- /client/src/api/.npmignore: -------------------------------------------------------------------------------- 1 | # empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm -------------------------------------------------------------------------------- /client/src/api/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /client/src/api/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | .openapi-generator-ignore 4 | api.ts 5 | base.ts 6 | common.ts 7 | configuration.ts 8 | docs/BrowserPolicy.md 9 | docs/BrowserPolicyContent.md 10 | docs/BrowserPolicyExtension.md 11 | docs/ConfigApi.md 12 | docs/DefaultApi.md 13 | docs/PullLayer.md 14 | docs/PullLayerProgressDetail.md 15 | docs/PullStart.md 16 | docs/PullStatus.md 17 | docs/RoomEntry.md 18 | docs/RoomMember.md 19 | docs/RoomMount.md 20 | docs/RoomResources.md 21 | docs/RoomSettings.md 22 | docs/RoomStats.md 23 | docs/RoomsApi.md 24 | docs/RoomsConfig.md 25 | git_push.sh 26 | index.ts 27 | -------------------------------------------------------------------------------- /client/src/api/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 7.14.0-SNAPSHOT 2 | -------------------------------------------------------------------------------- /client/src/api/base.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Neko Rooms 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import type { Configuration } from './configuration'; 17 | // Some imports not used depending on template conditions 18 | // @ts-ignore 19 | import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; 20 | import globalAxios from 'axios'; 21 | 22 | export const BASE_PATH = "https://virtserver.swaggerhub.com/m1k1o/neko-rooms/1.0.0".replace(/\/+$/, ""); 23 | 24 | /** 25 | * 26 | * @export 27 | */ 28 | export const COLLECTION_FORMATS = { 29 | csv: ",", 30 | ssv: " ", 31 | tsv: "\t", 32 | pipes: "|", 33 | }; 34 | 35 | /** 36 | * 37 | * @export 38 | * @interface RequestArgs 39 | */ 40 | export interface RequestArgs { 41 | url: string; 42 | options: RawAxiosRequestConfig; 43 | } 44 | 45 | /** 46 | * 47 | * @export 48 | * @class BaseAPI 49 | */ 50 | export class BaseAPI { 51 | protected configuration: Configuration | undefined; 52 | 53 | constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { 54 | if (configuration) { 55 | this.configuration = configuration; 56 | this.basePath = configuration.basePath ?? basePath; 57 | } 58 | } 59 | }; 60 | 61 | /** 62 | * 63 | * @export 64 | * @class RequiredError 65 | * @extends {Error} 66 | */ 67 | export class RequiredError extends Error { 68 | constructor(public field: string, msg?: string) { 69 | super(msg); 70 | this.name = "RequiredError" 71 | } 72 | } 73 | 74 | interface ServerMap { 75 | [key: string]: { 76 | url: string, 77 | description: string, 78 | }[]; 79 | } 80 | 81 | /** 82 | * 83 | * @export 84 | */ 85 | export const operationServerMap: ServerMap = { 86 | } 87 | -------------------------------------------------------------------------------- /client/src/api/common.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Neko Rooms 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import type { Configuration } from "./configuration"; 17 | import type { RequestArgs } from "./base"; 18 | import type { AxiosInstance, AxiosResponse } from 'axios'; 19 | import { RequiredError } from "./base"; 20 | 21 | /** 22 | * 23 | * @export 24 | */ 25 | export const DUMMY_BASE_URL = 'https://example.com' 26 | 27 | /** 28 | * 29 | * @throws {RequiredError} 30 | * @export 31 | */ 32 | export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { 33 | if (paramValue === null || paramValue === undefined) { 34 | throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); 35 | } 36 | } 37 | 38 | /** 39 | * 40 | * @export 41 | */ 42 | export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { 43 | if (configuration && configuration.apiKey) { 44 | const localVarApiKeyValue = typeof configuration.apiKey === 'function' 45 | ? await configuration.apiKey(keyParamName) 46 | : await configuration.apiKey; 47 | object[keyParamName] = localVarApiKeyValue; 48 | } 49 | } 50 | 51 | /** 52 | * 53 | * @export 54 | */ 55 | export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { 56 | if (configuration && (configuration.username || configuration.password)) { 57 | object["auth"] = { username: configuration.username, password: configuration.password }; 58 | } 59 | } 60 | 61 | /** 62 | * 63 | * @export 64 | */ 65 | export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { 66 | if (configuration && configuration.accessToken) { 67 | const accessToken = typeof configuration.accessToken === 'function' 68 | ? await configuration.accessToken() 69 | : await configuration.accessToken; 70 | object["Authorization"] = "Bearer " + accessToken; 71 | } 72 | } 73 | 74 | /** 75 | * 76 | * @export 77 | */ 78 | export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { 79 | if (configuration && configuration.accessToken) { 80 | const localVarAccessTokenValue = typeof configuration.accessToken === 'function' 81 | ? await configuration.accessToken(name, scopes) 82 | : await configuration.accessToken; 83 | object["Authorization"] = "Bearer " + localVarAccessTokenValue; 84 | } 85 | } 86 | 87 | function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void { 88 | if (parameter == null) return; 89 | if (typeof parameter === "object") { 90 | if (Array.isArray(parameter)) { 91 | (parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key)); 92 | } 93 | else { 94 | Object.keys(parameter).forEach(currentKey => 95 | setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`) 96 | ); 97 | } 98 | } 99 | else { 100 | if (urlSearchParams.has(key)) { 101 | urlSearchParams.append(key, parameter); 102 | } 103 | else { 104 | urlSearchParams.set(key, parameter); 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * 111 | * @export 112 | */ 113 | export const setSearchParams = function (url: URL, ...objects: any[]) { 114 | const searchParams = new URLSearchParams(url.search); 115 | setFlattenedQueryParams(searchParams, objects); 116 | url.search = searchParams.toString(); 117 | } 118 | 119 | /** 120 | * 121 | * @export 122 | */ 123 | export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { 124 | const nonString = typeof value !== 'string'; 125 | const needsSerialization = nonString && configuration && configuration.isJsonMime 126 | ? configuration.isJsonMime(requestOptions.headers['Content-Type']) 127 | : nonString; 128 | return needsSerialization 129 | ? JSON.stringify(value !== undefined ? value : {}) 130 | : (value || ""); 131 | } 132 | 133 | /** 134 | * 135 | * @export 136 | */ 137 | export const toPathString = function (url: URL) { 138 | return url.pathname + url.search + url.hash 139 | } 140 | 141 | /** 142 | * 143 | * @export 144 | */ 145 | export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { 146 | return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { 147 | const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url}; 148 | return axios.request(axiosRequestArgs); 149 | }; 150 | } 151 | -------------------------------------------------------------------------------- /client/src/api/configuration.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Neko Rooms 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export interface ConfigurationParameters { 17 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 18 | username?: string; 19 | password?: string; 20 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 21 | basePath?: string; 22 | serverIndex?: number; 23 | baseOptions?: any; 24 | formDataCtor?: new () => any; 25 | } 26 | 27 | export class Configuration { 28 | /** 29 | * parameter for apiKey security 30 | * @param name security name 31 | * @memberof Configuration 32 | */ 33 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 34 | /** 35 | * parameter for basic security 36 | * 37 | * @type {string} 38 | * @memberof Configuration 39 | */ 40 | username?: string; 41 | /** 42 | * parameter for basic security 43 | * 44 | * @type {string} 45 | * @memberof Configuration 46 | */ 47 | password?: string; 48 | /** 49 | * parameter for oauth2 security 50 | * @param name security name 51 | * @param scopes oauth2 scope 52 | * @memberof Configuration 53 | */ 54 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 55 | /** 56 | * override base path 57 | * 58 | * @type {string} 59 | * @memberof Configuration 60 | */ 61 | basePath?: string; 62 | /** 63 | * override server index 64 | * 65 | * @type {number} 66 | * @memberof Configuration 67 | */ 68 | serverIndex?: number; 69 | /** 70 | * base options for axios calls 71 | * 72 | * @type {any} 73 | * @memberof Configuration 74 | */ 75 | baseOptions?: any; 76 | /** 77 | * The FormData constructor that will be used to create multipart form data 78 | * requests. You can inject this here so that execution environments that 79 | * do not support the FormData class can still run the generated client. 80 | * 81 | * @type {new () => FormData} 82 | */ 83 | formDataCtor?: new () => any; 84 | 85 | constructor(param: ConfigurationParameters = {}) { 86 | this.apiKey = param.apiKey; 87 | this.username = param.username; 88 | this.password = param.password; 89 | this.accessToken = param.accessToken; 90 | this.basePath = param.basePath; 91 | this.serverIndex = param.serverIndex; 92 | this.baseOptions = { 93 | ...param.baseOptions, 94 | headers: { 95 | ...param.baseOptions?.headers, 96 | }, 97 | }; 98 | this.formDataCtor = param.formDataCtor; 99 | } 100 | 101 | /** 102 | * Check if the given MIME is a JSON MIME. 103 | * JSON MIME examples: 104 | * application/json 105 | * application/json; charset=UTF8 106 | * APPLICATION/JSON 107 | * application/vnd.company+json 108 | * @param mime - MIME (Multipurpose Internet Mail Extensions) 109 | * @return True if the given MIME is JSON, false otherwise. 110 | */ 111 | public isJsonMime(mime: string): boolean { 112 | const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); 113 | return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /client/src/api/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Neko Rooms 5 | * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) 6 | * 7 | * The version of the OpenAPI document: 1.0.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export * from "./api"; 17 | export * from "./configuration"; 18 | 19 | -------------------------------------------------------------------------------- /client/src/assets/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $text-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;; 2 | $text-size: 14px; 3 | $text-normal: #dcddde; 4 | $text-muted: #72767d; 5 | $text-link: #00b0f4; 6 | 7 | $interactive-normal: #b9bbbe; 8 | $interactive-hover: #dcddde; 9 | $interactive-muted: #4f545c; 10 | 11 | $background-primary: #36393f; 12 | $background-secondary: #2f3136; 13 | $background-tertiary: #202225; 14 | $background-accent: #4f545c; 15 | $background-floating: #18191c; 16 | $background-modifier-hover: rgba(79, 84, 92, 0.16); 17 | $background-modifier-active: rgba(79, 84, 92, 0.24); 18 | $background-modifier-selected: rgba(79, 84, 92, 0.32); 19 | $background-modifier-accent: hsla(0, 0%, 100%, 0.06); 20 | 21 | $elevation-low: 0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05); 22 | $elevation-high: 0 8px 16px rgba(0, 0, 0, 0.24); 23 | 24 | $style-primary: #19bd9c; 25 | $style-error: #d32f2f; 26 | 27 | $menu-height: 40px; 28 | $controls-height: 125px; 29 | $side-width: 400px; 30 | -------------------------------------------------------------------------------- /client/src/assets/styles/main.scss: -------------------------------------------------------------------------------- 1 | // Import variables 2 | @import "variables"; 3 | 4 | // Import Vendor 5 | @import "vendor/sweetalert2"; 6 | 7 | html, body { 8 | -webkit-font-smoothing: subpixel-antialiased; 9 | background-color: $background-tertiary; 10 | font-family: $text-family; 11 | font-size: $text-size; 12 | color: $text-normal; 13 | overflow: hidden; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/assets/styles/vendor/_sweetalert2.scss: -------------------------------------------------------------------------------- 1 | @import '~sweetalert2/src/variables'; 2 | 3 | $swal2-outline-color: transparent; 4 | 5 | // POPUP 6 | $swal2-padding: 1.25em; 7 | $swal2-border-radius: .3125em; 8 | 9 | // BACKGROUND 10 | $swal2-background: $background-secondary; 11 | 12 | // ICONS 13 | $swal2-icon-margin: 1.25em auto 1.875em; 14 | 15 | // IMAGE 16 | $swal2-image-margin: 1.25em auto; 17 | 18 | // TITLE 19 | $swal2-title-margin: 0 0 .4em; 20 | $swal2-title-color: $interactive-hover; 21 | 22 | // HTML CONTAINER 23 | $swal2-html-container-margin: 0; 24 | $swal2-html-container-color: $interactive-hover; 25 | 26 | // INPUT 27 | $swal2-input-margin: 1em auto; 28 | $swal2-input-width: 100%; 29 | $swal2-input-box-shadow: inset 0 1px 1px rgba($swal2-black, .06); 30 | $swal2-input-focus-box-shadow: 0 0 3px #c4e6f5; 31 | 32 | // PROGRESS STEPS 33 | $swal2-progress-steps-background: inherit; 34 | $swal2-progress-steps-margin: 0 0 1.25em; 35 | $swal2-active-step-background: #3085d6; 36 | 37 | // FOOTER 38 | $swal2-footer-margin: 1.25em 0 0; 39 | $swal2-footer-padding: 1em 0 0; 40 | $swal2-footer-color: lighten($swal2-black, 33); 41 | 42 | // CLOSE BUTTON 43 | $swal2-close-button-transition: color .1s ease-out; 44 | $swal2-close-button-border-radius: 0; 45 | $swal2-close-button-outline: initial; 46 | $swal2-close-button-color: lighten($swal2-black, 80); 47 | 48 | // ACTIONS 49 | $swal2-actions-width: 100%; 50 | 51 | // COMMON VARIABLES FOR CONFIRM AND CANCEL BUTTONS 52 | $swal2-button-focus-box-shadow: 0 0 0 1px $swal2-background, 0 0 0 3px $swal2-outline-color; 53 | 54 | // CONFIRM BUTTON 55 | $swal2-confirm-button-background-color: $background-tertiary; 56 | $swal2-confirm-button-font-size: 1.0625em; 57 | $swal2-confirm-button-focus-box-shadow: 0 0 0 3px rgba($swal2-confirm-button-background-color, .5); 58 | 59 | // CANCEL BUTTON 60 | $swal2-cancel-button-background-color: $background-floating; 61 | $swal2-cancel-button-font-size: 1.0625em; 62 | $swal2-cancel-button-focus-box-shadow: 0 0 0 3px rgba($swal2-cancel-button-background-color, .5); 63 | 64 | // TOASTS 65 | $swal2-toast-box-shadow: 0 0 .625em #d9d9d9; 66 | $swal2-toast-width: auto; 67 | $swal2-toast-padding: .625em; 68 | $swal2-toast-title-margin: 0 .6em; 69 | 70 | @import "~sweetalert2/src/sweetalert2.scss"; 71 | -------------------------------------------------------------------------------- /client/src/components/Pull.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 152 | -------------------------------------------------------------------------------- /client/src/components/RoomActionBtn.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 135 | -------------------------------------------------------------------------------- /client/src/components/RoomLink.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 97 | -------------------------------------------------------------------------------- /client/src/components/RoomsList.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 135 | -------------------------------------------------------------------------------- /client/src/components/RoomsQuick.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 106 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import './plugins/filters.ts' 4 | import sweetalert from './plugins/sweetalert' 5 | import store from './store' 6 | import vuetify from './plugins/vuetify' 7 | 8 | import '@/assets/styles/main.scss'; 9 | 10 | Vue.config.productionTip = false 11 | 12 | Vue.use(sweetalert) 13 | 14 | new Vue({ 15 | store, 16 | vuetify, 17 | render: h => h(App) 18 | }).$mount('#app') 19 | -------------------------------------------------------------------------------- /client/src/plugins/filters.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import moment from 'moment' 3 | 4 | // eslint-disable-next-line 5 | Vue.filter('datetime', function(value: any) { 6 | if (value) { 7 | return moment(String(value)).format('MM/DD/YYYY hh:mm') 8 | } 9 | }) 10 | 11 | // eslint-disable-next-line 12 | Vue.filter('timeago', function(value: any) { 13 | if (value) { 14 | return moment(String(value)).fromNow() 15 | } 16 | }) 17 | 18 | // eslint-disable-next-line 19 | Vue.filter('percent', function(value: any) { 20 | return (Math.floor(value * 10000) / 100) + '%' 21 | }) 22 | 23 | // eslint-disable-next-line 24 | Vue.filter('memory', function(value: any) { 25 | if (value < 1e3) { 26 | return value + 'B' 27 | } 28 | 29 | if (value < 1e6) { 30 | return (value / 1e3).toFixed(0) + 'K' 31 | } 32 | 33 | if (value < 1e9) { 34 | return (value / 1e6).toFixed(0) + 'M' 35 | } 36 | 37 | return (value / 1e9).toFixed(1) + 'G' 38 | }) 39 | 40 | // eslint-disable-next-line 41 | Vue.filter('nanocpus', function(value: any) { 42 | return (value / 1e9).toFixed(1) + 'x' 43 | }) 44 | -------------------------------------------------------------------------------- /client/src/plugins/sweetalert.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import Vue from 'vue' 4 | 5 | import { SweetAlertOptions } from 'sweetalert2' 6 | import Swal from 'sweetalert2/dist/sweetalert2.js' 7 | 8 | type VueSwalInstance = typeof Swal.fire 9 | 10 | declare module 'vue/types/vue' { 11 | interface Vue { 12 | $swal: VueSwalInstance 13 | } 14 | 15 | interface VueConstructor { 16 | swal: VueSwalInstance 17 | } 18 | } 19 | 20 | interface VueSweetalert2Options extends SweetAlertOptions { 21 | // includeCss?: boolean; 22 | } 23 | 24 | class VueSweetalert2 { 25 | static install(vue: Vue | any, options?: VueSweetalert2Options): void { 26 | const swalFunction = (...args: [SweetAlertOptions]) => { 27 | if (options) { 28 | const mixed = Swal.mixin(options) 29 | 30 | return mixed.fire(...args) 31 | } 32 | 33 | return Swal.fire(...args) 34 | } 35 | 36 | let methodName: string | number | symbol 37 | 38 | for (methodName in Swal) { 39 | // @ts-ignore 40 | if (Object.prototype.hasOwnProperty.call(Swal, methodName) && typeof Swal[methodName] === 'function') { 41 | // @ts-ignore 42 | swalFunction[methodName] = ((method) => { 43 | return (...args: any[]) => { 44 | // @ts-ignore 45 | return Swal[method](...args) 46 | } 47 | })(methodName) 48 | } 49 | } 50 | 51 | vue['swal'] = swalFunction 52 | 53 | // add the instance method 54 | if (!vue.prototype.hasOwnProperty('$swal')) { 55 | vue.prototype.$swal = swalFunction 56 | } 57 | } 58 | } 59 | 60 | export default VueSweetalert2 61 | -------------------------------------------------------------------------------- /client/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify/lib/framework' 3 | 4 | Vue.use(Vuetify) 5 | 6 | export default new Vuetify({ 7 | }) 8 | -------------------------------------------------------------------------------- /client/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /client/src/shims-vuetify.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vuetify/lib/framework' { 2 | import Vuetify from 'vuetify' 3 | export default Vuetify 4 | } 5 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex, { ActionContext } from 'vuex' 3 | 4 | import { 5 | Configuration, 6 | RoomsConfig, 7 | ConfigApi, 8 | RoomEntry, 9 | RoomSettings, 10 | RoomStats, 11 | RoomsApi, 12 | DefaultApi, 13 | PullStatus, 14 | } from '@/api/index' 15 | 16 | import { state, State } from './state' 17 | 18 | Vue.use(Vuex) 19 | 20 | const configuration = new Configuration({ 21 | basePath: (location.protocol + '//' + location.host + location.pathname).replace(/\/+$/, ''), 22 | }) 23 | 24 | const configApi = new ConfigApi(configuration) 25 | const roomsApi = new RoomsApi(configuration) 26 | const defaultApi = new DefaultApi(configuration) 27 | 28 | export default new Vuex.Store({ 29 | state, 30 | mutations: { 31 | ROOMS_CONFIG_SET(state: State, roomsConfig: RoomsConfig) { 32 | Vue.set(state, 'roomsConfig', roomsConfig) 33 | }, 34 | ROOMS_SET(state: State, roomEntries: RoomEntry[]) { 35 | Vue.set(state, 'rooms', roomEntries) 36 | }, 37 | ROOMS_ADD(state: State, roomEntry: RoomEntry) { 38 | // check if room already exists 39 | if (state.rooms.some(({ id }) => id == roomEntry.id)) { 40 | // replace room 41 | Vue.set(state, 'rooms', state.rooms.map((room) => { 42 | if (room.id == roomEntry.id) { 43 | return roomEntry 44 | } else { 45 | return room 46 | } 47 | })) 48 | } else { 49 | // add room 50 | Vue.set(state, 'rooms', [roomEntry, ...state.rooms]) 51 | } 52 | }, 53 | ROOMS_PUT(state: State, roomEntry: RoomEntry) { 54 | let exists = false 55 | const roomEntries = state.rooms.map((room) => { 56 | if (room.id == roomEntry.id) { 57 | exists = true 58 | return { ...room, ...roomEntry } 59 | } else { 60 | return room 61 | } 62 | }) 63 | 64 | if (exists) { 65 | Vue.set(state, 'rooms', roomEntries) 66 | } else { 67 | Vue.set(state, 'rooms', [roomEntry, ...roomEntries]) 68 | } 69 | }, 70 | ROOMS_DEL(state: State, roomId: string) { 71 | const roomEntries = state.rooms.filter(({ id }) => id != roomId) 72 | Vue.set(state, 'rooms', roomEntries) 73 | }, 74 | PULL_STATUS(state: State, pullStatus: PullStatus) { 75 | Vue.set(state, 'pullStatus', pullStatus) 76 | }, 77 | }, 78 | actions: { 79 | async ROOMS_CONFIG({ commit }: ActionContext) { 80 | const res = await configApi.roomsConfig() 81 | commit('ROOMS_CONFIG_SET', res.data); 82 | }, 83 | async ROOMS_LOAD({ commit }: ActionContext) { 84 | const res = await roomsApi.roomsList() 85 | commit('ROOMS_SET', res.data); 86 | }, 87 | async ROOMS_CREATE({ commit }: ActionContext, roomSettings: RoomSettings): Promise { 88 | const res = await roomsApi.roomCreate(roomSettings, false) 89 | commit('ROOMS_ADD', res.data); 90 | return res.data 91 | }, 92 | async ROOMS_CREATE_AND_START({ commit }: ActionContext, roomSettings: RoomSettings): Promise { 93 | const res = await roomsApi.roomCreate(roomSettings, true) 94 | commit('ROOMS_ADD', res.data); 95 | return res.data 96 | }, 97 | async ROOMS_GET({ commit }: ActionContext, roomId: string) { 98 | const res = await roomsApi.roomGet(roomId) 99 | commit('ROOMS_PUT', res.data); 100 | return res.data 101 | }, 102 | async ROOMS_REMOVE({ commit }: ActionContext, roomId: string) { 103 | await roomsApi.roomRemove(roomId) 104 | commit('ROOMS_DEL', roomId); 105 | }, 106 | async ROOMS_SETTINGS(_: ActionContext, roomId: string): Promise { 107 | const res = await roomsApi.roomSettings(roomId) 108 | return res.data 109 | }, 110 | async ROOMS_STATS(_: ActionContext, roomId: string): Promise { 111 | const res = await roomsApi.roomStats(roomId) 112 | return res.data 113 | }, 114 | async ROOMS_START({ commit }: ActionContext, roomId: string) { 115 | await roomsApi.roomStart(roomId) 116 | commit('ROOMS_PUT', { 117 | id: roomId, 118 | running: true, 119 | paused: false, 120 | status: 'Up', 121 | }); 122 | }, 123 | async ROOMS_STOP({ commit }: ActionContext, roomId: string) { 124 | await roomsApi.roomStop(roomId) 125 | commit('ROOMS_PUT', { 126 | id: roomId, 127 | running: false, 128 | paused: false, 129 | status: 'Exited', 130 | }); 131 | }, 132 | async ROOMS_PAUSE({ commit }: ActionContext, roomId: string) { 133 | await roomsApi.roomPause(roomId) 134 | commit('ROOMS_PUT', { 135 | id: roomId, 136 | running: false, 137 | paused: true, 138 | status: 'Paused', 139 | }); 140 | }, 141 | async ROOMS_RESTART(_: ActionContext, roomId: string) { 142 | await roomsApi.roomRestart(roomId) 143 | }, 144 | async ROOMS_RECREATE({ commit }: ActionContext, roomId: string) { 145 | const res = await roomsApi.roomRecreate(roomId, {} as RoomSettings) 146 | commit('ROOMS_DEL', roomId) 147 | commit('ROOMS_PUT', res.data) 148 | return res.data 149 | }, 150 | 151 | async PULL_START({ commit }: ActionContext, nekoImage: string) { 152 | const res = await defaultApi.pullStart({ 153 | // eslint-disable-next-line 154 | neko_image: nekoImage, 155 | }) 156 | commit('PULL_STATUS', res.data) 157 | return res.data 158 | }, 159 | async PULL_STATUS({ commit }: ActionContext) { 160 | const res = await defaultApi.pullStatus() 161 | commit('PULL_STATUS', res.data) 162 | }, 163 | async PULL_STOP() { 164 | const res = await defaultApi.pullStop() 165 | return res.data 166 | }, 167 | 168 | async EVENTS_SSE(): Promise { 169 | return new EventSource(configuration.basePath + '/api/events?sse', { 170 | withCredentials: true, 171 | }) 172 | }, 173 | }, 174 | modules: { 175 | } 176 | }) 177 | -------------------------------------------------------------------------------- /client/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | export function randomPassword() { 2 | return Math.random().toString(36).substring(2, 7) 3 | } 4 | -------------------------------------------------------------------------------- /client/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 214 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "useDefineForClassFields": true, 15 | "sourceMap": true, 16 | "baseUrl": ".", 17 | "types": [ 18 | "node", 19 | "webpack-env" 20 | ], 21 | "paths": { 22 | "~/*": [ 23 | "src/*" 24 | ], 25 | "@/*": [ 26 | "src/*" 27 | ] 28 | }, 29 | "lib": [ 30 | "esnext", 31 | "dom", 32 | "dom.iterable", 33 | "scripthost" 34 | ] 35 | }, 36 | "include": [ 37 | "src/**/*.ts", 38 | "src/**/*.tsx", 39 | "src/**/*.vue", 40 | "tests/**/*.ts", 41 | "tests/**/*.tsx" 42 | ], 43 | "exclude": [ 44 | "node_modules" 45 | ] 46 | } -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('@vue/cli-service') 2 | module.exports = defineConfig({ 3 | productionSourceMap: false, 4 | transpileDependencies: [ 5 | 'vuetify' 6 | ], 7 | publicPath: './', 8 | assetsDir: './', 9 | devServer: { 10 | allowedHosts: "all", 11 | proxy: process.env.API_PROXY ? { 12 | '^/api': { 13 | target: process.env.API_PROXY, 14 | timeout: 0, // because of SSE 15 | }, 16 | } : undefined, 17 | compress: false, // because of SSE 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /cmd/neko_rooms/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/rs/zerolog/log" 7 | 8 | nekoRooms "github.com/m1k1o/neko-rooms" 9 | "github.com/m1k1o/neko-rooms/cmd" 10 | "github.com/m1k1o/neko-rooms/internal/utils" 11 | ) 12 | 13 | func main() { 14 | fmt.Print(utils.Colorf(nekoRooms.Header, "server", nekoRooms.Service.Version)) 15 | if err := cmd.Execute(); err != nil { 16 | log.Panic().Err(err).Msg("failed to execute command") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | "time" 11 | 12 | "github.com/rs/zerolog" 13 | "github.com/rs/zerolog/diode" 14 | "github.com/rs/zerolog/log" 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/viper" 17 | 18 | nekoRooms "github.com/m1k1o/neko-rooms" 19 | ) 20 | 21 | func Execute() error { 22 | return root.Execute() 23 | } 24 | 25 | var root = &cobra.Command{ 26 | Use: "neko-rooms", 27 | Short: "neko-rooms server", 28 | Long: `neko-rooms server`, 29 | Version: nekoRooms.Service.Version.String(), 30 | } 31 | 32 | func init() { 33 | cobra.OnInitialize(func() { 34 | ////// 35 | // logs 36 | ////// 37 | zerolog.TimeFieldFormat = "" 38 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 39 | 40 | if viper.GetBool("debug") { 41 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 42 | } 43 | 44 | console := zerolog.ConsoleWriter{Out: os.Stdout} 45 | 46 | if !viper.GetBool("logs") { 47 | log.Logger = log.Output(console) 48 | } else { 49 | 50 | logs := filepath.Join(".", "logs") 51 | if runtime.GOOS == "linux" { 52 | // TODO: Change to neko-rooms. 53 | logs = "/var/log/neko_rooms" 54 | } 55 | 56 | if _, err := os.Stat(logs); os.IsNotExist(err) { 57 | os.Mkdir(logs, os.ModePerm) 58 | } 59 | 60 | // TODO: Change to neko-rooms. 61 | latest := filepath.Join(logs, "neko_rooms-latest.log") 62 | _, err := os.Stat(latest) 63 | if err == nil { 64 | // TODO: Change to neko-rooms. 65 | err = os.Rename(latest, filepath.Join(logs, "neko_rooms."+time.Now().Format("2006-01-02T15-04-05Z07-00")+".log")) 66 | if err != nil { 67 | log.Panic().Err(err).Msg("failed to rotate log file") 68 | } 69 | } 70 | 71 | logf, err := os.OpenFile(latest, os.O_RDWR|os.O_CREATE, 0666) 72 | if err != nil { 73 | log.Panic().Err(err).Msg("failed to create log file") 74 | } 75 | 76 | logger := diode.NewWriter(logf, 1000, 10*time.Millisecond, func(missed int) { 77 | fmt.Printf("logger dropped %d messages", missed) 78 | }) 79 | 80 | log.Logger = log.Output(io.MultiWriter(console, logger)) 81 | } 82 | 83 | ////// 84 | // configs 85 | ////// 86 | config := viper.GetString("config") 87 | if config != "" { 88 | viper.SetConfigFile(config) // Use config file from the flag. 89 | } else { 90 | if runtime.GOOS == "linux" { 91 | // TODO: Change to neko-rooms. 92 | viper.AddConfigPath("/etc/neko_rooms/") 93 | } 94 | 95 | viper.AddConfigPath(".") 96 | // TODO: Change to config. 97 | viper.SetConfigName("neko_rooms") 98 | } 99 | 100 | viper.SetEnvPrefix("NEKO_ROOMS") 101 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 102 | viper.AutomaticEnv() // read in environment variables that match 103 | 104 | if err := viper.ReadInConfig(); err != nil { 105 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 106 | log.Error().Err(err) 107 | } 108 | if config != "" { 109 | log.Error().Err(err) 110 | } 111 | } 112 | 113 | file := viper.ConfigFileUsed() 114 | logger := log.With(). 115 | Bool("debug", viper.GetBool("debug")). 116 | Str("logging", viper.GetString("logs")). 117 | Str("config", file). 118 | Logger() 119 | 120 | if file == "" { 121 | logger.Warn().Msg("preflight complete without config file") 122 | } else { 123 | logger.Info().Msg("preflight complete") 124 | } 125 | 126 | nekoRooms.Service.Configs.Root.Set() 127 | }) 128 | 129 | if err := nekoRooms.Service.Configs.Root.Init(root); err != nil { 130 | log.Panic().Err(err).Msg("unable to run root command") 131 | } 132 | 133 | root.SetVersionTemplate(nekoRooms.Service.Version.Details()) 134 | } 135 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | "github.com/spf13/cobra" 6 | 7 | nekoRooms "github.com/m1k1o/neko-rooms" 8 | "github.com/m1k1o/neko-rooms/internal/config" 9 | ) 10 | 11 | func init() { 12 | command := &cobra.Command{ 13 | Use: "serve", 14 | Short: "serve neko-rooms server", 15 | Long: `serve neko-rooms server`, 16 | Run: nekoRooms.Service.ServeCommand, 17 | } 18 | 19 | configs := []config.Config{ 20 | nekoRooms.Service.Configs.Server, 21 | nekoRooms.Service.Configs.Room, 22 | } 23 | 24 | cobra.OnInitialize(func() { 25 | for _, cfg := range configs { 26 | cfg.Set() 27 | } 28 | nekoRooms.Service.Preflight() 29 | }) 30 | 31 | for _, cfg := range configs { 32 | if err := cfg.Init(command); err != nil { 33 | log.Panic().Err(err).Msg("unable to run serve command") 34 | } 35 | } 36 | 37 | root.AddCommand(command) 38 | } 39 | -------------------------------------------------------------------------------- /community/README.md: -------------------------------------------------------------------------------- 1 | # Community Installation Scripts 2 | 3 | This directory contains community-maintained installation scripts for various operating systems and configurations. While these scripts are not officially supported, they can be helpful for setting up neko-rooms on different platforms. 4 | 5 | ## Available Scripts 6 | 7 | ### AWS Linux 8 | For Amazon Linux 2 or higher: 9 | ```bash 10 | wget -O neko-rooms-aws.sh https://raw.githubusercontent.com/m1k1o/neko-rooms/refs/heads/master/community/scripts/aws-linux.sh 11 | sudo bash neko-rooms-aws.sh 12 | ``` 13 | 14 | ### Ubuntu/Debian with Nginx 15 | For Ubuntu 20.04 or higher, or Debian 10 or higher: 16 | ```bash 17 | wget -O neko-rooms.sh https://raw.githubusercontent.com/m1k1o/neko-rooms/refs/heads/master/community/scripts/ubuntu-debian.sh 18 | sudo bash neko-rooms.sh 19 | ``` 20 | -------------------------------------------------------------------------------- /dev/.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | ext 3 | -------------------------------------------------------------------------------- /dev/api-gen: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf ../client/src/api 4 | mkdir ../client/src/api 5 | 6 | docker run --rm \ 7 | --user "$(id -u):$(id -g)" \ 8 | -v "${PWD}/../client/src/api:/local/out" \ 9 | -v "${PWD}/../OpenApi.yml:/local/in.yaml" \ 10 | openapitools/openapi-generator-cli generate \ 11 | -i /local/in.yaml \ 12 | -g typescript-axios \ 13 | -o /local/out \ 14 | --additional-properties=enumPropertyNaming=original,modelPropertyNaming=original 15 | 16 | # Remove not needed git_push.sh 17 | rm -f ../client/src/api/git_push.sh 18 | 19 | # Fix lint errors 20 | docker run --rm -it \ 21 | --user "$(id -u):$(id -g)" \ 22 | -v "${PWD}/../client:/app" \ 23 | --entrypoint="npm" \ 24 | --workdir="/app" \ 25 | node:20-bookworm-slim run lint -- --fix src/api 26 | -------------------------------------------------------------------------------- /dev/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | docker build --target builder -t neko_rooms_img .. 5 | 6 | ./rebuild 7 | -------------------------------------------------------------------------------- /dev/go: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | docker run -it \ 5 | --name "neko_rooms_dev" \ 6 | --entrypoint="go" \ 7 | --volume "${PWD}/../:/app" \ 8 | neko_rooms_img "$@"; 9 | 10 | # 11 | # commit changes to image 12 | docker commit "neko_rooms_dev" "neko_rooms_img" 13 | 14 | # 15 | # remove contianer 16 | docker rm "neko_rooms_dev" 17 | -------------------------------------------------------------------------------- /dev/rebuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | set -e 5 | 6 | docker run --rm -it \ 7 | -v "${PWD}/../:/app" \ 8 | --entrypoint="go" \ 9 | neko_rooms_img build -o bin/neko_rooms cmd/neko_rooms/main.go 10 | 11 | ./start 12 | -------------------------------------------------------------------------------- /dev/serve: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | if [ -z $APP_PORT ]; then 5 | APP_PORT="8080" 6 | fi 7 | 8 | if [ -z $APP_HOST ]; then 9 | for i in $(ifconfig -l 2>/dev/null); do 10 | APP_HOST=$(ipconfig getifaddr $i) 11 | if [ ! -z $APP_HOST ]; then 12 | break 13 | fi 14 | done 15 | 16 | if [ -z $APP_HOST ]; then 17 | APP_HOST=$(hostname -I 2>/dev/null | awk '{print $1}') 18 | fi 19 | 20 | if [ -z $APP_HOST ]; then 21 | APP_HOST=$(hostname -i 2>/dev/null) 22 | fi 23 | fi 24 | 25 | echo "Using app port: ${APP_PORT}" 26 | echo "Using IP address: ${APP_HOST}" 27 | 28 | docker run --rm -it \ 29 | -p 8081:8080 \ 30 | -e "API_PROXY=http://${APP_HOST}:${APP_PORT}" \ 31 | --user="$(id -u):$(id -g)" \ 32 | --volume "${PWD}/../client:/app" \ 33 | --entrypoint="npm" \ 34 | --workdir="/app" \ 35 | node:20-bookworm-slim run serve; 36 | -------------------------------------------------------------------------------- /dev/start: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | if [ -z $NEKO_ROOMS_PORT ]; then 5 | NEKO_ROOMS_PORT="8080" 6 | fi 7 | 8 | if [ -z $NEKO_ROOMS_EPR ]; then 9 | NEKO_ROOMS_EPR="52090-52099" 10 | fi 11 | 12 | if [ -z $NEKO_ROOMS_NAT1TO1 ]; then 13 | for i in $(ifconfig -l 2>/dev/null); do 14 | NEKO_ROOMS_NAT1TO1=$(ipconfig getifaddr $i) 15 | if [ ! -z $NEKO_ROOMS_NAT1TO1 ]; then 16 | break 17 | fi 18 | done 19 | 20 | if [ -z $NEKO_ROOMS_NAT1TO1 ]; then 21 | NEKO_ROOMS_NAT1TO1=$(hostname -I 2>/dev/null | awk '{print $1}') 22 | fi 23 | 24 | if [ -z $NEKO_ROOMS_NAT1TO1 ]; then 25 | NEKO_ROOMS_NAT1TO1=$(hostname -i 2>/dev/null) 26 | fi 27 | fi 28 | 29 | NEKO_ROOMS_INSTANCE_NETWORK="neko-rooms-net" 30 | docker network create --attachable "${NEKO_ROOMS_INSTANCE_NETWORK}"; 31 | 32 | trap on_exit EXIT 33 | 34 | on_exit() { 35 | echo "Removing neko-rooms network" 36 | docker network rm "${NEKO_ROOMS_INSTANCE_NETWORK}"; 37 | } 38 | 39 | DATA_PATH="./data" 40 | mkdir -p "${DATA_PATH}" 41 | 42 | EXTERNAL_PATH="./ext" 43 | mkdir -p "${EXTERNAL_PATH}" 44 | 45 | docker run --rm -it \ 46 | --name="neko_rooms_server" \ 47 | -p "${NEKO_ROOMS_PORT}:8080" \ 48 | -v "`realpath ..`:/app" \ 49 | -v "`realpath ${DATA_PATH}`:/data" \ 50 | -e "TZ=${TZ}" \ 51 | -e "NEKO_ROOMS_MUX=true" \ 52 | -e "NEKO_ROOMS_EPR=${NEKO_ROOMS_EPR}" \ 53 | -e "NEKO_ROOMS_NAT1TO1=${NEKO_ROOMS_NAT1TO1}" \ 54 | -e "NEKO_ROOMS_INSTANCE_URL=http://${NEKO_ROOMS_NAT1TO1}:${NEKO_ROOMS_PORT}/" \ 55 | -e "NEKO_ROOMS_INSTANCE_NETWORK=${NEKO_ROOMS_INSTANCE_NETWORK}" \ 56 | -e "NEKO_ROOMS_STORAGE_INTERNAL=/data" \ 57 | -e "NEKO_ROOMS_STORAGE_EXTERNAL=`realpath ${DATA_PATH}`" \ 58 | -e "NEKO_ROOMS_MOUNTS_WHITELIST=`realpath ${EXTERNAL_PATH}`" \ 59 | -e "NEKO_ROOMS_PATH_PREFIX=/room/" \ 60 | -e "NEKO_ROOMS_TRAEFIK_ENABLED=false" \ 61 | -e 'DOCKER_API_VERSION=1.39' \ 62 | -v "/var/run/docker.sock:/var/run/docker.sock" \ 63 | --net="${NEKO_ROOMS_INSTANCE_NETWORK}" \ 64 | --entrypoint="/app/bin/neko_rooms" \ 65 | neko_rooms_img serve --bind :8080; 66 | -------------------------------------------------------------------------------- /dev/traefik/.env.example: -------------------------------------------------------------------------------- 1 | NEKO_ROOMS_EPR=59000-59049 2 | NEKO_ROOMS_NAT1TO1=192.168.1.20 3 | 4 | NEKO_ROOMS_TRAEFIK_DOMAIN=neko-rooms.server.lan 5 | NEKO_ROOMS_TRAEFIK_ENTRYPOINT=websecure 6 | NEKO_ROOMS_TRAEFIK_NETWORK=neko-rooms-traefik 7 | NEKO_ROOMS_TRAEFIK_CERTRESOLVER=lets-encrypt 8 | 9 | TZ=Europe/Vienna 10 | -------------------------------------------------------------------------------- /dev/traefik/rebuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | set -e 5 | 6 | docker run --rm -it \ 7 | -v "${PWD}/../../:/app" \ 8 | --entrypoint="go" \ 9 | neko_rooms_img build -o bin/neko_rooms cmd/neko_rooms/main.go 10 | 11 | ./start 12 | -------------------------------------------------------------------------------- /dev/traefik/serve: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | if [ ! -f ".env" ] 5 | then 6 | echo ".env file not found!" 7 | exit 1 8 | fi 9 | 10 | export $(cat .env | sed 's/#.*//g' | xargs) 11 | 12 | docker run --rm -it \ 13 | --name="neko_rooms_client" \ 14 | -v "${PWD}/../../client:/app" \ 15 | -e "TZ=${TZ}" \ 16 | --net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \ 17 | -l "traefik.enable=true" \ 18 | -l "traefik.http.services.neko-rooms-client-fe.loadbalancer.server.port=8080" \ 19 | -l "traefik.http.routers.neko-rooms-client.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \ 20 | -l "traefik.http.routers.neko-rooms-client.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`)" \ 21 | -l "traefik.http.routers.neko-rooms-client.priority=1" \ 22 | --user="$(id -u):$(id -g)" \ 23 | --workdir="/app" \ 24 | --entrypoint="npm" \ 25 | node:20-bookworm-slim run serve; 26 | -------------------------------------------------------------------------------- /dev/traefik/start: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | if [ ! -f ".env" ] 5 | then 6 | echo ".env file not found!" 7 | exit 1 8 | fi 9 | 10 | export $(cat .env | sed 's/#.*//g' | xargs) 11 | 12 | DATA_PATH="../data" 13 | mkdir -p "${DATA_PATH}" 14 | 15 | EXTERNAL_PATH="../ext" 16 | mkdir -p "${EXTERNAL_PATH}" 17 | 18 | docker run --rm -it \ 19 | --name="neko_rooms_server" \ 20 | -v "`realpath ../../`:/app" \ 21 | -v "`realpath ${DATA_PATH}`:/data" \ 22 | -e "TZ=${TZ}" \ 23 | -e "NEKO_ROOMS_EPR=${NEKO_ROOMS_EPR}" \ 24 | -e "NEKO_ROOMS_MUX=true" \ 25 | -e "NEKO_ROOMS_NAT1TO1=${NEKO_ROOMS_NAT1TO1}" \ 26 | -e "NEKO_ROOMS_PATH_PREFIX=/room/" \ 27 | -e "NEKO_ROOMS_STORAGE_INTERNAL=/data" \ 28 | -e "NEKO_ROOMS_STORAGE_EXTERNAL=`realpath ${DATA_PATH}`" \ 29 | -e "NEKO_ROOMS_MOUNTS_WHITELIST=`realpath ${EXTERNAL_PATH}`" \ 30 | -e "NEKO_ROOMS_TRAEFIK_ENABLED=true" \ 31 | -e "NEKO_ROOMS_TRAEFIK_DOMAIN=${NEKO_ROOMS_TRAEFIK_DOMAIN}" \ 32 | -e "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \ 33 | -e "NEKO_ROOMS_TRAEFIK_NETWORK=${NEKO_ROOMS_TRAEFIK_NETWORK}" \ 34 | -e 'DOCKER_API_VERSION=1.39' \ 35 | -v "/var/run/docker.sock:/var/run/docker.sock" \ 36 | --net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \ 37 | -l "traefik.enable=true" \ 38 | -l "traefik.http.services.neko-rooms-server-fe.loadbalancer.server.port=8080" \ 39 | -l "traefik.http.routers.neko-rooms-server.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \ 40 | -l "traefik.http.routers.neko-rooms-server.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`) && PathPrefix(\`/room\`)" \ 41 | -l "traefik.http.routers.neko-rooms-server.priority=2" \ 42 | -l "traefik.http.routers.neko-rooms-server.service=neko-rooms-server-fe" \ 43 | -l "traefik.http.routers.neko-rooms-server-api.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \ 44 | -l "traefik.http.routers.neko-rooms-server-api.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`) && PathPrefix(\`/api\`)" \ 45 | -l "traefik.http.routers.neko-rooms-server-api.service=neko-rooms-server-fe" \ 46 | --entrypoint="/app/bin/neko_rooms" \ 47 | neko_rooms_img serve --bind :8080; 48 | -------------------------------------------------------------------------------- /dev/traefik/traefik: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | 4 | if [ ! -f ".env" ] 5 | then 6 | echo ".env file not found!" 7 | exit 1 8 | fi 9 | 10 | export $(cat .env | sed 's/#.*//g' | xargs) 11 | 12 | docker network create --attachable "${NEKO_ROOMS_TRAEFIK_NETWORK}"; 13 | 14 | trap on_exit EXIT 15 | 16 | on_exit() { 17 | echo "Removing traefik network" 18 | docker network rm "${NEKO_ROOMS_TRAEFIK_NETWORK}"; 19 | } 20 | 21 | docker run --rm -it \ 22 | --name="neko_rooms_traefik" \ 23 | -p "${1:-80}:80" \ 24 | -p "8080:8080" \ 25 | -v "${PWD}/../../:/app" \ 26 | -e "TZ=${TZ}" \ 27 | -v "/var/run/docker.sock:/var/run/docker.sock" \ 28 | --net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \ 29 | traefik:2.4 \ 30 | --api.insecure=true \ 31 | --providers.docker=true \ 32 | --providers.docker.watch=true \ 33 | --providers.docker.exposedbydefault=false \ 34 | --providers.docker.network=${NEKO_ROOMS_TRAEFIK_NETWORK} \ 35 | --entrypoints.${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}.address=:80; 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | networks: 4 | default: 5 | attachable: true 6 | name: "neko-rooms-net" 7 | 8 | services: 9 | neko-rooms: 10 | image: "m1k1o/neko-rooms:latest" 11 | restart: "unless-stopped" 12 | environment: 13 | - "TZ=Europe/Vienna" 14 | - "NEKO_ROOMS_MUX=true" 15 | - "NEKO_ROOMS_EPR=59000-59049" 16 | - "NEKO_ROOMS_NAT1TO1=127.0.0.1" # IP address of your server that is reachable from client 17 | - "NEKO_ROOMS_INSTANCE_URL=http://127.0.0.1:8080/" # external URL 18 | - "NEKO_ROOMS_INSTANCE_NETWORK=neko-rooms-net" 19 | - "NEKO_ROOMS_TRAEFIK_ENABLED=false" 20 | - "NEKO_ROOMS_PATH_PREFIX=/room/" 21 | ports: 22 | - "8080:8080" 23 | volumes: 24 | - "/var/run/docker.sock:/var/run/docker.sock" 25 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Docs 2 | 3 | Case study: having 2 rooms, every with max 5 users, they only need 10 ports to be forwarded so the range can be `59000-59009`. 4 | 5 | ![architecture](./architecture.svg) 6 | 7 | All clients visit `https://neko-rooms/room-name/`. Once they logged in, they get one of the ports allocated where the RTC data flows. It happens inside SDP negotiation and is completly transparent to the clients. Therefore those ports needs to remain open, but they will never be actually visited directly in the browser. 8 | 9 | `NEKO_ROOMS_NAT1TO1` must be the IP where the mentioned UDP ports are forwarded. If this setting is not present, it will get automatically servers public IP at start of every room that will be sent to clients. 10 | 11 | ## Connection timeout 12 | 13 | Neko room loads but you don't see the screen and it gives you `connection timeout` or `disconnected error`? [Validate](https://neko.m1k1o.net/#/getting-started/troubleshooting?id=validate-udp-ports-reachability) that your UDP ports are reachable. 14 | 15 | ## path prefix 16 | 17 | Room names are by default put to root directory. If you want to have custom path prefix, you can specify it using env variable: 18 | 19 | ``` 20 | NEKO_ROOMS_PATH_PREFIX=/test/ 21 | ``` 22 | 23 | Now room will be available at `example.org/test/` instead of `example.org/`. 24 | 25 | ## using mux 26 | 27 | When using mux, there will be allocated two ports per room: TCP and UDP port with the same number. 28 | 29 | ``` 30 | NEKO_ROOMS_MUX=true 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/architecture.drawio: -------------------------------------------------------------------------------- 1 | 7Vvdl5o4FP9rfNQDBBQf1ZnZnrNtT8/pObvdfYsQNTtILMRR+9dvAgmQEJRx8GPazkObXJKQ3Pu7n8EemK33fyRws/pEQhT1HCvc98BDz3Fse+Sz/zjlkFOGwMsJywSHYlBJ+Ip/IEG0BHWLQ5QqAykhEcUblRiQOEYBVWgwSchOHbYgkfrWDVyiGuFrAKM69W8c0lVO9T2rpH9AeLmSb7Yt8WQN5WBBSFcwJLsKCTz2wCwhhOat9X6GIs48yZd83lPD02JjCYppmwnf//3zr+3n6WISWPZ6N32A4aek7zr5Mi8w2ooTi93Sg2RBQrZxiPgqVg9MdytM0dcNDPjTHRM6o63oOmI9mzUXOIpmJCJJNheEEPmLgNFTmpBnVHkyDHw0X7AnYgMooWjfeDS7YBhDGiJrRJMDGyImAMHig8SO6O9KiYGRoK0q0gJyIBQoWRZLl4xkDcHLV/B1VOMiChmuRJckdEWWJIbRY0mdqnwux3wkZCO4+x+i9CCUBG4pUXmP9ph+q7T/4UsNHE90H/Zi6axzkJ2YnfdbtZNPG3myX87LenJifkJ+rONSY1wg2yRAR7hlj4Vmw2SJ6LGBrhkHCYogxS/qTkxCFVO/EMz2WODHHqsAAkDDRX4CMauqY9pChbLLhYbaQvkJawtlGCvOcz7s5Our6gwmvuUz+hMNNkZUfoRzZrQVJMEIL2PWDpgoEVPYKVdPzKziRDxY4zDMQYtS/APOs/U4Kjb8aNlhvWnPe2CUiC8/hcHzMgO4NAExidEx7RcGXSxdmtEqvppVr9FU9K2BOwYgX+uNsAGa1VHnk8UiRReRsm+w2cOI8WQa4hfWXNLMmpA1G2TLR+xNlaeGCZK0INkZU+mKh9+33EVN7bLJHSkM1MczpiKYYcWxPqOdMjQojH5JBOPsr0pSd4EVnMpRfG/9fGcTNsDf7OsrMOBQiGO+leLkWF+e0fJzquR5Yhio8OgtvrEDT+dqng7UPZ1vcHS6GerMz41Phw8s7tnw5iJC+wmPyDJPEormQxDBNMWByimVrWf5NT0Q8ZAfuqZAxHfmYDjMnsCEym3NIxI8d+vlfLNkK6LzDKKTtI5dnO2f6eKGlraQ613Vxdl2DXLe2LKsPv+XhQdP2/Ambq57RzY+5ciAZ/uKLEadQMXxXGVV6W8u79hsUzbS5Nmc357tFZ7tvv2YY8jYrurHbLcN8mgC0QI//wbe+wWenqEVSLwZ8rw3h1AyVmkMoDqKhdgrnzA/3avy/1tFPEALVGoiPDfiqS106Yhn2MY2lSk+e4vlukB2W6nhuwyQctW5QYRku2rq37fBtUIkULcON6ssjtpWFgdur1JbtHvHC4tZ7wtKMOMVB11GlCYwwnHHaVnr4mMD2lpj6U1uQpasKkYgZFY5iw5S3qC30Olr1/Zy9DdqvDWwmPvoRMVlBb/QcC3LvWASZKqp1Mx9jJ5Jn2dC6e9o9P1Go1oaBG6dBhliz2t4l4YrqFM3UGd5pS49h9PSc1zq2mqkwcc9M8LVL1Cd8XVrek7duf1y11bOcdd2uXur/vUurhz3nsyLc+/2xb8v8+J0Zl70u4cG8zJJEnioDBNK2rhhR7+aGHnH96Un9p4ynjXyHXSrA4Z6zy9n6xoA+1PZulFdzl3X9a50MarW/d50TXqz60+97KsD4exioL7QpUOl+scf/OLTy64/xz/T9adz8kOea91/XrG6Z/h+q8yX86QVRkznIGVGgJ0UxkuRKpeX4AUKTNmxhgwmLXqTTHdYz3QLHa3qvx5zdJbqmq5daiUTWWL7xSosKUpeMNvcu6+vAP17KbsOO5PXqVn17uq4d/Rl8IlMRiu5N6VRPovUzijot40Y2tRhhL2+eAW/IbbQ0hvgadWTtrEF0L8e1o3fhWMLt9n7VJR6gRO0g3y1p9aGkQGYNtvRWxdh7XszEm69HvaBpPUbHuG9K+xQg3iRwFUjfkFqH/qZ+K9KqAMR2L6niMCvS2BoksDFBND8OZD07accq+2YPGvxobEkDAaDilrMT7rZu0TA8foBP8ATXOOIC1eNcfKHwm35oltJQUWI0wnGPC1xM0SgJozpPwXpDmPNEajAWC1A/ICiF8SFVAfWLMIoK461w4307dt1NAkoqco/SxS/kBRTTDgO5oRSsjYAhBLNFjMjz2IFhj35U7zusGE7dXBY2V834BjqPyEb1dEBDOg44yaOdcsf/eWuu/zpJHj8Hw== -------------------------------------------------------------------------------- /docs/dind/.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | -------------------------------------------------------------------------------- /docs/dind/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker:dind 2 | 3 | COPY nrooms-* / 4 | 5 | VOLUME [ "/data" ] 6 | 7 | ENTRYPOINT [ "/nrooms-entrypoint.sh" ] 8 | -------------------------------------------------------------------------------- /docs/dind/README.md: -------------------------------------------------------------------------------- 1 | # dind: Docker in Docker 2 | 3 | Neko rooms manages neko containers in docker and routes them using traefik. This is whole implementation put inside docker, that runs in docker. It is additional layer of security, but still not perfect (because dind requires `--privileged`). 4 | 5 | This overhead of putting docker in docker can have negative impact on the usability, therefore users are encouraged to **not deploy neko-rooms using dind** method. 6 | 7 | However, there are some usecases, when this might come in handy: 8 | - just testing out neko-rooms 9 | - absolutely needed additional security layer 10 | - developing neko-rooms 11 | 12 | ## Pull images 13 | 14 | In order to pull new images run: 15 | 16 | ```sh 17 | docker-compose exec neko-rooms docker pull ghcr.io/m1k1o/neko/chromium 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/dind/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | services: 4 | neko-rooms: 5 | build: "./" 6 | restart: "unless-stopped" 7 | privileged: true 8 | environment: 9 | - "TZ=Europe/Vienna" 10 | - "NEKO_ROOMS_EPR=59000-59049" 11 | - "NEKO_ROOMS_NAT1TO1=10.8.0.1" # IP address of your server 12 | - "NEKO_ROOMS_INSTANCE_URL=http://10.8.0.1:8080/" # external URL 13 | volumes: 14 | - ./data/storage:/data 15 | - ./data/docker:/var/lib/docker 16 | - ./data/certs:/certs 17 | ports: 18 | - 8080:80 19 | - 59000-59049:59000-59049/udp 20 | -------------------------------------------------------------------------------- /docs/dind/nrooms-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | /nrooms-start.sh & 4 | source /usr/local/bin/dockerd-entrypoint.sh 5 | -------------------------------------------------------------------------------- /docs/dind/nrooms-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # wait for docker to start 5 | # 6 | 7 | until docker ps 8 | do 9 | echo "waiting for docker..." 10 | sleep 1 11 | done 12 | 13 | # 14 | # create traefik network 15 | # 16 | 17 | docker network create --attachable traefik 18 | 19 | # 20 | # (re)start traefik 21 | # 22 | 23 | docker stop traefik 24 | docker rm traefik 25 | docker run -d \ 26 | --restart unless-stopped \ 27 | --name traefik \ 28 | --network=traefik \ 29 | -p "80:80" \ 30 | -v "/var/run/docker.sock:/var/run/docker.sock:ro" \ 31 | -e "TZ=${TZ}" \ 32 | traefik:2.4 \ 33 | --providers.docker=true \ 34 | --providers.docker.watch=true \ 35 | --providers.docker.exposedbydefault=false \ 36 | --providers.docker.network=traefik \ 37 | --entrypoints.web.address=:80; 38 | 39 | # pull some neko images... 40 | docker pull ghcr.io/m1k1o/neko/firefox 41 | docker pull ghcr.io/m1k1o/neko/chromium 42 | 43 | # 44 | # (re)start neko-rooms 45 | # 46 | 47 | docker stop nrooms 48 | docker rm nrooms 49 | docker run -t \ 50 | --restart unless-stopped \ 51 | --name nrooms \ 52 | --network=traefik \ 53 | -v "/var/run/docker.sock:/var/run/docker.sock" \ 54 | -v "/data:/data" \ 55 | -e "TZ=${TZ}" \ 56 | -e "NEKO_ROOMS_EPR=${NEKO_ROOMS_EPR}" \ 57 | -e "NEKO_ROOMS_NAT1TO1=${NEKO_ROOMS_NAT1TO1}" \ 58 | -e "NEKO_ROOMS_INSTANCE_URL=${NEKO_ROOMS_INSTANCE_URL}" \ 59 | -e "NEKO_ROOMS_TRAEFIK_DOMAIN=*" \ 60 | -e "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=web" \ 61 | -e "NEKO_ROOMS_TRAEFIK_NETWORK=traefik" \ 62 | -e "NEKO_ROOMS_STORAGE_ENABLED=true" \ 63 | -e "NEKO_ROOMS_STORAGE_INTERNAL=/data" \ 64 | -e "NEKO_ROOMS_STORAGE_EXTERNAL=/data" \ 65 | -l "traefik.enable=true" \ 66 | -l "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080" \ 67 | -l "traefik.http.routers.neko-rooms.entrypoints=web" \ 68 | -l 'traefik.http.routers.neko-rooms.rule=HostRegexp(`{host:.+}`)' \ 69 | -l 'traefik.http.routers.neko-rooms.priority=1' \ 70 | m1k1o/neko-rooms:latest; 71 | -------------------------------------------------------------------------------- /docs/lables.md: -------------------------------------------------------------------------------- 1 | # custom labels 2 | 3 | You can add custom labels for every room. 4 | 5 | Example: Expose port 3000 from every room under `3000-(room-name)`: 6 | 7 | ```bash 8 | -e "NEKO_ROOMS_INSTANCE_LABELS= 9 | traefik.http.services.{containerName}-3000-tcp.loadbalancer.server.port=3000 10 | traefik.http.routers.{containerName}-3000-tcp.entrypoints={traefikEntrypoint} 11 | traefik.http.routers.{containerName}-3000-tcp.rule=PathPrefix(\`/3000-{roomName}\`) 12 | traefik.http.middlewares.{containerName}-3000-tcp-prf.stripprefix.prefixes=/3000-{roomName}/ 13 | traefik.http.routers.{containerName}-3000-tcp.middlewares={containerName}-3000-tcp-prf 14 | traefik.http.routers.{containerName}-3000-tcp.service={containerName}-3000-tcp 15 | " 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/neko.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1k1o/neko-rooms/da890b40fc32cb8565b8fdce96e4571b33cd739f/docs/neko.gif -------------------------------------------------------------------------------- /docs/new_room.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1k1o/neko-rooms/da890b40fc32cb8565b8fdce96e4571b33cd739f/docs/new_room.png -------------------------------------------------------------------------------- /docs/rooms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1k1o/neko-rooms/da890b40fc32cb8565b8fdce96e4571b33cd739f/docs/rooms.png -------------------------------------------------------------------------------- /docs/storage.md: -------------------------------------------------------------------------------- 1 | # Storage for neko-rooms 2 | 3 | This needs to be specified in docker-compose: 4 | 5 | ```diff 6 | neko-rooms: 7 | image: "m1k1o/neko-rooms:latest" 8 | restart: "unless-stopped" 9 | environment: 10 | - "TZ" 11 | - "NEKO_ROOMS_EPR" 12 | - "NEKO_ROOMS_NAT1TO1" 13 | - "NEKO_ROOMS_TRAEFIK_DOMAIN" 14 | - "NEKO_ROOMS_TRAEFIK_ENTRYPOINT" 15 | - "NEKO_ROOMS_TRAEFIK_NETWORK" 16 | - "NEKO_ROOMS_INSTANCE_URL=http://${NEKO_ROOMS_TRAEFIK_DOMAIN}:8080/" # external URL 17 | + - "NEKO_ROOMS_STORAGE_ENABLED=true" 18 | + - "NEKO_ROOMS_STORAGE_INTERNAL=/data" 19 | + - "NEKO_ROOMS_STORAGE_EXTERNAL=/opt/neko-rooms/data" 20 | volumes: 21 | - "/var/run/docker.sock:/var/run/docker.sock" 22 | + - "/opt/neko-rooms/data:/data" 23 | labels: 24 | - "traefik.enable=true" 25 | - "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080" 26 | - "traefik.http.routers.neko-rooms.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" 27 | - "traefik.http.routers.neko-rooms.rule=Host(`${NEKO_ROOMS_TRAEFIK_DOMAIN}`)" 28 | ``` 29 | 30 | Where: 31 | - `NEKO_ROOMS_STORAGE_INTERNAL` is the directory inside the container. 32 | - `NEKO_ROOMS_STORAGE_EXTERNAL` is the directory outside container. 33 | - `"/opt/neko-rooms/data:/data"` is volume mount. 34 | 35 | Please note, that **neko-rooms** must be aware of external storage path, as it is going to mount it to the room itself. That needs to be available to **neko-rooms** as well, in order to manage that folder. 36 | 37 | Inside storage path (e.g. `/opt/neko-rooms/data`) there will be available these mount points: 38 | 39 | - `/opt/neko-rooms/data/`**`rooms//`** where will be stored private room data. 40 | - `/opt/neko-rooms/data/`**`templates/`** where templates will be accessible. 41 | 42 | ## How can it be used? 43 | 44 | You can mount e.g. browser policies and this way customize browser. 45 | 46 | You can always refer to [google-chrome Dockerfile](https://github.com/m1k1o/neko/blob/1800d077d8138bdb23c25028bf4201a0469f91aa/.m1k1o/google-chrome/Dockerfile). In this case, policies are mounted to `/etc/opt/chrome/policies/managed/policies.json` path inside container. So you can mount custom file to this location what overwrites its content. 47 | 48 | For this purpose, template path type is recommended, as policy file should only be readonly and can be reused along multiple rooms. You can then store your policies file to e.g. `/opt/neko-rooms/data/templates/policies.json` and have it mounted to all rooms. 49 | 50 | ![Storage](storage.png) 51 | 52 | ## Mount path whitelist 53 | 54 | If you want to mount any path from your filesystem, you need to whitelist it first. 55 | 56 | Add it as environment variables to your docker compose: 57 | 58 | ```yaml 59 | NEKO_ROOMS_MOUNTS_WHITELIST: "/media" 60 | ``` 61 | 62 | Or when using multiple, they can be separated white space: 63 | 64 | ```yaml 65 | NEKO_ROOMS_MOUNTS_WHITELIST: "/home /media" 66 | ``` 67 | 68 | You can mount any path within your whitelisted path. Meaning, if you whitelisted `/home` folder you can selectively mount path e.g. `/home/ubuntu` to a room. 69 | 70 | **NOTICE:** You could whitelist all paths on your system with `/`. From security perspective, this solution is *strongly discouraged*. 71 | -------------------------------------------------------------------------------- /docs/storage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1k1o/neko-rooms/da890b40fc32cb8565b8fdce96e4571b33cd739f/docs/storage.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/m1k1o/neko-rooms 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/docker/cli v24.0.6+incompatible 7 | github.com/docker/docker v24.0.6+incompatible 8 | github.com/docker/go-connections v0.4.0 9 | github.com/go-chi/chi/v5 v5.0.10 10 | github.com/go-chi/cors v1.2.1 11 | github.com/prometheus/client_golang v1.17.0 12 | github.com/rs/zerolog v1.31.0 13 | github.com/spf13/cobra v1.7.0 14 | github.com/spf13/viper v1.16.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | ) 17 | 18 | require ( 19 | github.com/Microsoft/go-winio v0.6.1 // indirect 20 | github.com/beorn7/perks v1.0.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 22 | github.com/docker/distribution v2.8.2+incompatible // indirect 23 | github.com/docker/go-units v0.5.0 // indirect 24 | github.com/fsnotify/fsnotify v1.6.0 // indirect 25 | github.com/gogo/protobuf v1.3.2 // indirect 26 | github.com/golang/protobuf v1.5.3 // indirect 27 | github.com/hashicorp/hcl v1.0.0 // indirect 28 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 29 | github.com/magiconair/properties v1.8.7 // indirect 30 | github.com/mattn/go-colorable v0.1.13 // indirect 31 | github.com/mattn/go-isatty v0.0.19 // indirect 32 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 33 | github.com/mitchellh/mapstructure v1.5.0 // indirect 34 | github.com/moby/term v0.5.0 // indirect 35 | github.com/morikuni/aec v1.0.0 // indirect 36 | github.com/opencontainers/go-digest v1.0.0 // indirect 37 | github.com/opencontainers/image-spec v1.0.2 // indirect 38 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 39 | github.com/pkg/errors v0.9.1 // indirect 40 | github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect 41 | github.com/prometheus/common v0.44.0 // indirect 42 | github.com/prometheus/procfs v0.11.1 // indirect 43 | github.com/sirupsen/logrus v1.9.3 // indirect 44 | github.com/spf13/afero v1.9.5 // indirect 45 | github.com/spf13/cast v1.5.1 // indirect 46 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 47 | github.com/spf13/pflag v1.0.5 // indirect 48 | github.com/subosito/gotenv v1.4.2 // indirect 49 | golang.org/x/mod v0.8.0 // indirect 50 | golang.org/x/net v0.10.0 // indirect 51 | golang.org/x/sys v0.12.0 // indirect 52 | golang.org/x/text v0.9.0 // indirect 53 | golang.org/x/tools v0.6.0 // indirect 54 | google.golang.org/protobuf v1.31.0 // indirect 55 | gopkg.in/ini.v1 v1.67.0 // indirect 56 | gotest.tools/v3 v3.5.1 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/rs/zerolog" 6 | "github.com/rs/zerolog/log" 7 | 8 | "github.com/m1k1o/neko-rooms/internal/types" 9 | ) 10 | 11 | type ApiManagerCtx struct { 12 | logger zerolog.Logger 13 | rooms types.RoomManager 14 | pull types.PullManager 15 | } 16 | 17 | func New(rooms types.RoomManager, pull types.PullManager) *ApiManagerCtx { 18 | return &ApiManagerCtx{ 19 | logger: log.With().Str("module", "api").Logger(), 20 | rooms: rooms, 21 | pull: pull, 22 | } 23 | } 24 | 25 | func (manager *ApiManagerCtx) Mount(r chi.Router) { 26 | // 27 | // config 28 | // 29 | 30 | r.Get("/config/rooms", manager.configRooms) 31 | 32 | // 33 | // pull 34 | // 35 | 36 | r.Route("/pull", func(r chi.Router) { 37 | r.Get("/", manager.pullStatus) 38 | r.Get("/sse", manager.pullStatusSSE) 39 | r.Post("/", manager.pullStart) 40 | r.Delete("/", manager.pullStop) 41 | }) 42 | 43 | // 44 | // rooms 45 | // 46 | 47 | r.Get("/rooms", manager.roomsList) 48 | r.Post("/rooms", manager.roomCreate) 49 | 50 | r.Route("/rooms/{roomId}", func(r chi.Router) { 51 | r.Get("/", manager.roomGetEntry) 52 | r.Get("/by-name", manager.roomGetEntryByName) 53 | 54 | r.Get("/settings", manager.roomGetSettings) 55 | r.Get("/stats", manager.roomGetStats) 56 | 57 | r.Delete("/", manager.roomGenericAction(manager.rooms.Remove)) 58 | r.Post("/start", manager.roomGenericAction(manager.rooms.Start)) 59 | r.Post("/stop", manager.roomGenericAction(manager.rooms.Stop)) 60 | r.Post("/restart", manager.roomGenericAction(manager.rooms.Restart)) 61 | r.Post("/pause", manager.roomGenericAction(manager.rooms.Pause)) 62 | r.Post("/recreate", manager.roomRecreate) 63 | }) 64 | 65 | r.Get("/docker-compose.yaml", manager.dockerCompose) 66 | 67 | // 68 | // events 69 | // 70 | 71 | r.Get("/events", manager.events) 72 | } 73 | -------------------------------------------------------------------------------- /internal/api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | func (manager *ApiManagerCtx) configRooms(w http.ResponseWriter, r *http.Request) { 9 | response := manager.rooms.Config() 10 | 11 | w.Header().Set("Content-Type", "application/json") 12 | json.NewEncoder(w).Encode(response) 13 | } 14 | -------------------------------------------------------------------------------- /internal/api/events.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | func (manager *ApiManagerCtx) events(w http.ResponseWriter, r *http.Request) { 11 | w.Header().Set("Cache-Control", "no-cache") 12 | w.Header().Set("Connection", "keep-alive") 13 | 14 | sse := r.URL.Query().Has("sse") 15 | if sse { 16 | w.Header().Set("Content-Type", "text/event-stream") 17 | } else { 18 | w.Header().Set("Content-Type", "application/json") 19 | } 20 | 21 | flusher, ok := w.(http.Flusher) 22 | if !ok { 23 | http.Error(w, "Connection does not support streaming", http.StatusBadRequest) 24 | return 25 | } 26 | 27 | var ping <-chan time.Time 28 | if !sse { 29 | // dummy channel, never ping 30 | ping = make(<-chan time.Time) 31 | } else { 32 | // ping every 1 minute 33 | ticker := time.NewTicker(time.Minute) 34 | defer ticker.Stop() 35 | ping = ticker.C 36 | } 37 | 38 | // listen for room events 39 | events, errs := manager.rooms.Events(r.Context()) 40 | for { 41 | select { 42 | case <-ping: 43 | fmt.Fprintf(w, ": ping\n\n") 44 | flusher.Flush() 45 | case _, ok := <-errs: 46 | if !ok { 47 | manager.logger.Debug().Msg("sse channel closed") 48 | } 49 | return 50 | case e := <-events: 51 | jsonData, err := json.Marshal(e) 52 | if err != nil { 53 | manager.logger.Err(err).Msg("failed to marshal event") 54 | continue 55 | } 56 | 57 | if sse { 58 | fmt.Fprintf(w, "event: rooms\n") 59 | fmt.Fprintf(w, "data: %s\n\n", jsonData) 60 | } else { 61 | fmt.Fprintf(w, "rooms\t%s\n", jsonData) 62 | } 63 | 64 | flusher.Flush() 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/api/pull.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/m1k1o/neko-rooms/internal/types" 9 | ) 10 | 11 | func (manager *ApiManagerCtx) pullStart(w http.ResponseWriter, r *http.Request) { 12 | request := types.PullStart{} 13 | 14 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { 15 | http.Error(w, err.Error(), 400) 16 | return 17 | } 18 | 19 | err := manager.pull.Start(request) 20 | if err != nil { 21 | http.Error(w, err.Error(), 500) 22 | return 23 | } 24 | 25 | response := manager.pull.Status() 26 | w.Header().Set("Content-Type", "application/json") 27 | json.NewEncoder(w).Encode(response) 28 | } 29 | 30 | func (manager *ApiManagerCtx) pullStatus(w http.ResponseWriter, r *http.Request) { 31 | response := manager.pull.Status() 32 | w.Header().Set("Content-Type", "application/json") 33 | json.NewEncoder(w).Encode(response) 34 | } 35 | 36 | func (manager *ApiManagerCtx) pullStatusSSE(w http.ResponseWriter, r *http.Request) { 37 | w.Header().Set("Content-Type", "text/event-stream") 38 | w.Header().Set("Cache-Control", "no-cache") 39 | w.Header().Set("Connection", "keep-alive") 40 | w.Header().Set("Access-Control-Allow-Origin", "*") 41 | 42 | flusher, ok := w.(http.Flusher) 43 | if !ok { 44 | http.Error(w, "Connection does not support streaming", http.StatusBadRequest) 45 | return 46 | } 47 | 48 | sseChan := make(chan string) 49 | unsubscribe := manager.pull.Subscribe(sseChan) 50 | 51 | for { 52 | select { 53 | case <-r.Context().Done(): 54 | manager.logger.Debug().Msg("sse context done") 55 | unsubscribe() 56 | return 57 | case data, ok := <-sseChan: 58 | if !ok { 59 | manager.logger.Debug().Msg("sse channel closed") 60 | return 61 | } 62 | 63 | fmt.Fprintf(w, "data: %s\n\n", data) 64 | flusher.Flush() 65 | } 66 | } 67 | } 68 | 69 | func (manager *ApiManagerCtx) pullStop(w http.ResponseWriter, r *http.Request) { 70 | err := manager.pull.Stop() 71 | if err != nil { 72 | http.Error(w, err.Error(), 500) 73 | return 74 | } 75 | 76 | w.WriteHeader(http.StatusNoContent) 77 | } 78 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | type Config interface { 6 | Init(cmd *cobra.Command) error 7 | Set() 8 | } 9 | -------------------------------------------------------------------------------- /internal/config/root.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | type Root struct { 9 | Debug bool 10 | Logs bool 11 | CfgFile string 12 | } 13 | 14 | func (Root) Init(cmd *cobra.Command) error { 15 | cmd.PersistentFlags().BoolP("debug", "d", false, "enable debug mode") 16 | if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); err != nil { 17 | return err 18 | } 19 | 20 | cmd.PersistentFlags().BoolP("logs", "l", false, "save logs to file") 21 | if err := viper.BindPFlag("logs", cmd.PersistentFlags().Lookup("logs")); err != nil { 22 | return err 23 | } 24 | 25 | cmd.PersistentFlags().String("config", "", "configuration file path") 26 | if err := viper.BindPFlag("config", cmd.PersistentFlags().Lookup("config")); err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func (s *Root) Set() { 34 | s.Logs = viper.GetBool("logs") 35 | s.Debug = viper.GetBool("debug") 36 | s.CfgFile = viper.GetString("config") 37 | } 38 | -------------------------------------------------------------------------------- /internal/config/server.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | type Admin struct { 11 | Static string 12 | PathPrefix string 13 | ProxyAuth string 14 | Username string 15 | Password string 16 | } 17 | 18 | type Server struct { 19 | Cert string 20 | Key string 21 | Bind string 22 | Proxy bool 23 | CORS bool 24 | PProf bool 25 | Metrics bool 26 | 27 | Admin Admin 28 | } 29 | 30 | func (Server) Init(cmd *cobra.Command) error { 31 | cmd.PersistentFlags().String("bind", "127.0.0.1:8080", "address/port/socket to serve neko_rooms") 32 | if err := viper.BindPFlag("bind", cmd.PersistentFlags().Lookup("bind")); err != nil { 33 | return err 34 | } 35 | 36 | cmd.PersistentFlags().String("cert", "", "path to the SSL cert used to secure the neko_rooms server") 37 | if err := viper.BindPFlag("cert", cmd.PersistentFlags().Lookup("cert")); err != nil { 38 | return err 39 | } 40 | 41 | cmd.PersistentFlags().String("key", "", "path to the SSL key used to secure the neko_rooms server") 42 | if err := viper.BindPFlag("key", cmd.PersistentFlags().Lookup("key")); err != nil { 43 | return err 44 | } 45 | 46 | cmd.PersistentFlags().Bool("proxy", false, "trust reverse proxy headers") 47 | if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil { 48 | return err 49 | } 50 | 51 | cmd.PersistentFlags().Bool("cors", false, "enable CORS") 52 | if err := viper.BindPFlag("cors", cmd.PersistentFlags().Lookup("cors")); err != nil { 53 | return err 54 | } 55 | 56 | cmd.PersistentFlags().Bool("pprof", false, "enable pprof endpoint available at /debug/pprof") 57 | if err := viper.BindPFlag("pprof", cmd.PersistentFlags().Lookup("pprof")); err != nil { 58 | return err 59 | } 60 | 61 | cmd.PersistentFlags().Bool("metrics", false, "enable metrics endpoint available at /metrics") 62 | if err := viper.BindPFlag("metrics", cmd.PersistentFlags().Lookup("metrics")); err != nil { 63 | return err 64 | } 65 | 66 | // Admin 67 | 68 | cmd.PersistentFlags().String("admin.static", "", "path to neko_rooms admin client files to serve") 69 | if err := viper.BindPFlag("admin.static", cmd.PersistentFlags().Lookup("admin.static")); err != nil { 70 | return err 71 | } 72 | 73 | cmd.PersistentFlags().String("admin.path_prefix", "/", "path prefix for admin client and API") 74 | if err := viper.BindPFlag("admin.path_prefix", cmd.PersistentFlags().Lookup("admin.path_prefix")); err != nil { 75 | return err 76 | } 77 | 78 | cmd.PersistentFlags().String("admin.proxy_auth", "", "require auth: proxy authentication URL, only allow if it returns 200") 79 | if err := viper.BindPFlag("admin.proxy_auth", cmd.PersistentFlags().Lookup("admin.proxy_auth")); err != nil { 80 | return err 81 | } 82 | 83 | cmd.PersistentFlags().String("admin.username", "admin", "require auth: admin username") 84 | if err := viper.BindPFlag("admin.username", cmd.PersistentFlags().Lookup("admin.username")); err != nil { 85 | return err 86 | } 87 | 88 | cmd.PersistentFlags().String("admin.password", "", "require auth: admin password") 89 | if err := viper.BindPFlag("admin.password", cmd.PersistentFlags().Lookup("admin.password")); err != nil { 90 | return err 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func (s *Server) Set() { 97 | s.Cert = viper.GetString("cert") 98 | s.Key = viper.GetString("key") 99 | s.Bind = viper.GetString("bind") 100 | s.Proxy = viper.GetBool("proxy") 101 | s.CORS = viper.GetBool("cors") 102 | s.PProf = viper.GetBool("pprof") 103 | s.Metrics = viper.GetBool("metrics") 104 | 105 | s.Admin.Static = viper.GetString("admin.static") 106 | s.Admin.PathPrefix = path.Join("/", path.Clean(viper.GetString("admin.path_prefix"))) 107 | s.Admin.ProxyAuth = viper.GetString("admin.proxy_auth") 108 | s.Admin.Username = viper.GetString("admin.username") 109 | s.Admin.Password = viper.GetString("admin.password") 110 | } 111 | -------------------------------------------------------------------------------- /internal/policies/chromium/generator.go: -------------------------------------------------------------------------------- 1 | package chromium 2 | 3 | // https://chromeenterprise.google/policies/ 4 | 5 | import ( 6 | _ "embed" 7 | "encoding/json" 8 | "fmt" 9 | 10 | "github.com/m1k1o/neko-rooms/internal/types" 11 | ) 12 | 13 | //go:embed policies.json 14 | var policiesJson string 15 | 16 | func Generate(policies types.BrowserPolicyContent) (string, error) { 17 | policiesTmpl := map[string]any{} 18 | if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil { 19 | return "", err 20 | } 21 | 22 | // 23 | // Extensions 24 | // 25 | 26 | ExtensionInstallForcelist := []any{} 27 | for _, e := range policies.Extensions { 28 | URL := e.URL 29 | if URL == "" { 30 | URL = "https://clients2.google.com/service/update2/crx" 31 | } 32 | 33 | ExtensionInstallForcelist = append( 34 | ExtensionInstallForcelist, 35 | fmt.Sprintf("%s;%s", e.ID, URL), 36 | ) 37 | } 38 | 39 | ExtensionInstallAllowlist := []any{} 40 | for _, e := range policies.Extensions { 41 | ExtensionInstallAllowlist = append( 42 | ExtensionInstallAllowlist, 43 | e.ID, 44 | ) 45 | } 46 | 47 | policiesTmpl["ExtensionInstallForcelist"] = ExtensionInstallForcelist 48 | policiesTmpl["ExtensionInstallAllowlist"] = ExtensionInstallAllowlist 49 | policiesTmpl["ExtensionInstallBlocklist"] = []any{"*"} 50 | 51 | // 52 | // Developer Tools 53 | // 54 | 55 | if policies.DeveloperTools { 56 | // Allow usage of the Developer Tools 57 | policiesTmpl["DeveloperToolsAvailability"] = 1 58 | } else { 59 | // Disallow usage of the Developer Tools 60 | policiesTmpl["DeveloperToolsAvailability"] = 2 61 | } 62 | 63 | // 64 | // Persistent Data 65 | // 66 | 67 | if policies.PersistentData { 68 | // Allow all sites to set local data 69 | policiesTmpl["DefaultCookiesSetting"] = 1 70 | // Restore the last session 71 | policiesTmpl["RestoreOnStartup"] = 1 72 | } else { 73 | // Keep cookies for the duration of the session 74 | policiesTmpl["DefaultCookiesSetting"] = 4 75 | // Open New Tab Page 76 | policiesTmpl["RestoreOnStartup"] = 5 77 | } 78 | 79 | data, err := json.MarshalIndent(policiesTmpl, "", " ") 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | return string(data), nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/policies/chromium/parser.go: -------------------------------------------------------------------------------- 1 | package chromium 2 | 3 | // https://chromeenterprise.google/policies/ 4 | 5 | import ( 6 | "encoding/json" 7 | "strings" 8 | 9 | "github.com/m1k1o/neko-rooms/internal/types" 10 | ) 11 | 12 | func Parse(policiesJson string) (*types.BrowserPolicyContent, error) { 13 | policies := types.BrowserPolicyContent{} 14 | 15 | policiesTmpl := map[string]any{} 16 | if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil { 17 | return nil, err 18 | } 19 | 20 | // 21 | // Extensions 22 | // 23 | 24 | if extensions, ok := policiesTmpl["ExtensionInstallForcelist"]; ok { 25 | policies.Extensions = []types.BrowserPolicyExtension{} 26 | for _, val := range extensions.([]any) { 27 | s := strings.Split(val.(string), ";") 28 | url := "" 29 | if len(s) > 1 { 30 | url = s[1] 31 | } 32 | policies.Extensions = append( 33 | policies.Extensions, 34 | types.BrowserPolicyExtension{ 35 | ID: s[0], 36 | URL: url, 37 | }, 38 | ) 39 | } 40 | } 41 | 42 | // 43 | // Developer Tools 44 | // 45 | 46 | if val, ok := policiesTmpl["DeveloperToolsAvailability"]; ok { 47 | policies.DeveloperTools = val.(float64) == 1 48 | } 49 | 50 | // 51 | // Persistent Data 52 | // 53 | 54 | if val, ok := policiesTmpl["DefaultCookiesSetting"]; ok { 55 | policies.PersistentData = val.(float64) == 1 56 | } 57 | 58 | return &policies, nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/policies/chromium/policies.json: -------------------------------------------------------------------------------- 1 | { 2 | "AutofillAddressEnabled": false, 3 | "AutofillCreditCardEnabled": false, 4 | "BrowserSignin": 0, 5 | "DefaultNotificationsSetting": 2, 6 | "DeveloperToolsAvailability": 2, 7 | "EditBookmarksEnabled": false, 8 | "FullscreenAllowed": true, 9 | "IncognitoModeAvailability": 1, 10 | "SyncDisabled": true, 11 | "AutoplayAllowed": true, 12 | "BrowserAddPersonEnabled": false, 13 | "BrowserGuestModeEnabled": false, 14 | "DefaultPopupsSetting": 2, 15 | "DownloadRestrictions": 3, 16 | "VideoCaptureAllowed": true, 17 | "AllowFileSelectionDialogs": false, 18 | "PromptForDownloadLocation": false, 19 | "BookmarkBarEnabled": false, 20 | "PasswordManagerEnabled": false, 21 | "URLBlocklist": [ 22 | "file://*", 23 | "chrome://policy" 24 | ], 25 | "ExtensionInstallForcelist": [ 26 | "cjpalhdlnbpafiamejdnhcphjbkeiagm;https://clients2.google.com/service/update2/crx", 27 | "fjoaledfpmneenckfbpdfhkmimnjocfa;https://clients2.google.com/service/update2/crx" 28 | ], 29 | "ExtensionInstallAllowlist": [ 30 | "cjpalhdlnbpafiamejdnhcphjbkeiagm", 31 | "fjoaledfpmneenckfbpdfhkmimnjocfa" 32 | ], 33 | "ExtensionInstallBlocklist": [ 34 | "*" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /internal/policies/config.go: -------------------------------------------------------------------------------- 1 | package policies 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/m1k1o/neko-rooms/internal/policies/chromium" 7 | "github.com/m1k1o/neko-rooms/internal/policies/firefox" 8 | "github.com/m1k1o/neko-rooms/internal/types" 9 | ) 10 | 11 | func Generate(policies types.BrowserPolicyContent, policyType types.BrowserPolicyType) (string, error) { 12 | if policyType == types.ChromiumBrowserPolicy { 13 | return chromium.Generate(policies) 14 | } 15 | 16 | if policyType == types.FirefoxBrowserPolicy { 17 | return firefox.Generate(policies) 18 | } 19 | 20 | return "", errors.New("unknown policy type") 21 | } 22 | 23 | func Parse(policiesJson string, policyType types.BrowserPolicyType) (*types.BrowserPolicyContent, error) { 24 | if policyType == types.ChromiumBrowserPolicy { 25 | return chromium.Parse(policiesJson) 26 | } 27 | 28 | if policyType == types.FirefoxBrowserPolicy { 29 | return firefox.Parse(policiesJson) 30 | } 31 | 32 | return nil, errors.New("unknown policy type") 33 | } 34 | -------------------------------------------------------------------------------- /internal/policies/firefox/generator.go: -------------------------------------------------------------------------------- 1 | package firefox 2 | 3 | // https://github.com/mozilla/policy-templates/blob/master/README.md#homepage 4 | 5 | import ( 6 | _ "embed" 7 | "encoding/json" 8 | 9 | "github.com/m1k1o/neko-rooms/internal/types" 10 | ) 11 | 12 | //go:embed policies.json 13 | var policiesJson string 14 | 15 | func Generate(policies types.BrowserPolicyContent) (string, error) { 16 | policiesTmpl := struct { 17 | Policies map[string]any `json:"policies"` 18 | }{} 19 | if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil { 20 | return "", err 21 | } 22 | 23 | // 24 | // Extensions 25 | // 26 | 27 | ExtensionSettings := map[string]any{} 28 | ExtensionSettings["*"] = map[string]any{ 29 | "installation_mode": "blocked", 30 | } 31 | 32 | for _, e := range policies.Extensions { 33 | ExtensionSettings[e.ID] = map[string]any{ 34 | "install_url": e.URL, 35 | "installation_mode": "force_installed", 36 | } 37 | } 38 | 39 | policiesTmpl.Policies["ExtensionSettings"] = ExtensionSettings 40 | 41 | // 42 | // Developer Tools 43 | // 44 | 45 | policiesTmpl.Policies["DisableDeveloperTools"] = !policies.DeveloperTools 46 | 47 | // 48 | // Persistent Data 49 | // 50 | 51 | Preferences := policiesTmpl.Policies["Preferences"].(map[string]any) 52 | Preferences["browser.urlbar.suggest.history"] = policies.PersistentData 53 | Preferences["places.history.enabled"] = policies.PersistentData 54 | policiesTmpl.Policies["Preferences"] = Preferences 55 | policiesTmpl.Policies["SanitizeOnShutdown"] = !policies.PersistentData 56 | 57 | if policies.PersistentData { 58 | policiesTmpl.Policies["Homepage"] = map[string]any{ 59 | "StartPage": "previous-session", 60 | } 61 | } else { 62 | policiesTmpl.Policies["Homepage"] = map[string]any{ 63 | "StartPage": "homepage", 64 | } 65 | } 66 | 67 | data, err := json.MarshalIndent(policiesTmpl, "", " ") 68 | if err != nil { 69 | return "", err 70 | } 71 | 72 | return string(data), nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/policies/firefox/parser.go: -------------------------------------------------------------------------------- 1 | package firefox 2 | 3 | // https://github.com/mozilla/policy-templates/blob/master/README.md#homepage 4 | 5 | import ( 6 | _ "embed" 7 | "encoding/json" 8 | 9 | "github.com/m1k1o/neko-rooms/internal/types" 10 | ) 11 | 12 | func Parse(policiesJson string) (*types.BrowserPolicyContent, error) { 13 | policies := types.BrowserPolicyContent{} 14 | 15 | policiesTmpl := struct { 16 | Policies map[string]any `json:"policies"` 17 | }{} 18 | if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil { 19 | return nil, err 20 | } 21 | 22 | // empty file 23 | if policiesTmpl.Policies == nil { 24 | return &policies, nil 25 | } 26 | 27 | // 28 | // Extensions 29 | // 30 | 31 | if extensions, ok := policiesTmpl.Policies["ExtensionSettings"]; ok { 32 | policies.Extensions = []types.BrowserPolicyExtension{} 33 | for id, val := range extensions.(map[string]any) { 34 | if id == "*" { 35 | continue 36 | } 37 | 38 | data := val.(map[string]any) 39 | url, _ := data["install_url"].(string) 40 | 41 | policies.Extensions = append( 42 | policies.Extensions, 43 | types.BrowserPolicyExtension{ 44 | ID: id, 45 | URL: url, 46 | }, 47 | ) 48 | } 49 | } 50 | 51 | // 52 | // Developer Tools 53 | // 54 | 55 | if val, ok := policiesTmpl.Policies["DisableDeveloperTools"]; ok { 56 | policies.DeveloperTools = !val.(bool) 57 | } 58 | 59 | // 60 | // Persistent Data 61 | // 62 | 63 | if val, ok := policiesTmpl.Policies["SanitizeOnShutdown"]; ok { 64 | policies.PersistentData = !val.(bool) 65 | } 66 | 67 | return &policies, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/policies/firefox/policies.json: -------------------------------------------------------------------------------- 1 | { 2 | "policies": { 3 | "BlockAboutAddons": false, 4 | "BlockAboutConfig": true, 5 | "BlockAboutProfiles": true, 6 | "BlockAboutSupport": true, 7 | "Bookmarks": [ 8 | { 9 | "Title": "IPLeak", 10 | "URL": "https://ipleak.net/", 11 | "Favicon": "https://ipleak.net/favicon.ico", 12 | "Folder": "Pages", 13 | "Placement": "toolbar" 14 | }, 15 | { 16 | "Title": "YouTube", 17 | "URL": "https://www.youtube.com/", 18 | "Favicon": "https://www.youtube.com/favicon.ico", 19 | "Folder": "Pages", 20 | "Placement": "toolbar" 21 | }, 22 | { 23 | "Title": "Google", 24 | "URL": "https://www.google.com/", 25 | "Favicon": "https://www.google.com/favicon.ico", 26 | "Folder": "Pages", 27 | "Placement": "toolbar" 28 | } 29 | ], 30 | "CaptivePortal": false, 31 | "DisableAppUpdate": true, 32 | "DisableBuiltinPDFViewer": true, 33 | "DisableDeveloperTools": false, 34 | "DisableFeedbackCommands": true, 35 | "DisableFirefoxAccounts": true, 36 | "DisableFirefoxScreenshots": true, 37 | "DisableFirefoxStudies": true, 38 | "DisableForgetButton": true, 39 | "DisableMasterPasswordCreation": true, 40 | "DisablePocket": true, 41 | "DisablePrivateBrowsing": true, 42 | "DisableProfileImport": true, 43 | "DisableProfileRefresh": true, 44 | "DisableSafeMode": true, 45 | "DisableSetDesktopBackground": true, 46 | "DisableSystemAddonUpdate": true, 47 | "DisableTelemetry": true, 48 | "DisplayBookmarksToolbar": false, 49 | "DontCheckDefaultBrowser": true, 50 | "EnableTrackingProtection": { 51 | "Cryptomining": true, 52 | "Fingerprinting": true, 53 | "Value": true 54 | }, 55 | "ExtensionSettings": { 56 | "*": { 57 | "installation_mode": "blocked" 58 | }, 59 | "nordvpnproxy@nordvpn.com": { 60 | "install_url": "https://addons.mozilla.org/firefox/downloads/latest/nordvpn-proxy-extension/latest.xpi", 61 | "installation_mode": "force_installed" 62 | }, 63 | "uBlock0@raymondhill.net": { 64 | "install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi", 65 | "installation_mode": "force_installed" 66 | } 67 | }, 68 | "ExtensionUpdate": false, 69 | "FirefoxHome": { 70 | "Highlights": false, 71 | "Pocket": false, 72 | "Search": true, 73 | "Snippets": false, 74 | "TopSites": true 75 | }, 76 | "FlashPlugin": {}, 77 | "HardwareAcceleration": false, 78 | "Homepage": { 79 | "Additional": [], 80 | "StartPage": "home" 81 | }, 82 | "NewTabPage": true, 83 | "NoDefaultBookmarks": true, 84 | "OfferToSaveLogins": false, 85 | "OfferToSaveLoginsDefault": false, 86 | "OverrideFirstRunPage": "", 87 | "OverridePostUpdatePage": "", 88 | "PasswordManagerEnabled": false, 89 | "Permissions": { 90 | "Camera": { 91 | "BlockNewRequests": true 92 | }, 93 | "Location": { 94 | "BlockNewRequests": true 95 | }, 96 | "Microphone": { 97 | "BlockNewRequests": true 98 | }, 99 | "Notifications": { 100 | "BlockNewRequests": true 101 | } 102 | }, 103 | "Preferences": { 104 | "browser.tabs.warnOnClose": false, 105 | "browser.urlbar.suggest.bookmark": false, 106 | "browser.urlbar.suggest.history": false, 107 | "browser.urlbar.suggest.openpage": false, 108 | "datareporting.policy.dataSubmissionPolicyBypassNotification": true, 109 | "dom.disable_window_flip": true, 110 | "dom.disable_window_move_resize": true, 111 | "dom.event.contextmenu.enabled": false, 112 | "extensions.getAddons.showPane": false, 113 | "places.history.enabled": false, 114 | "privacy.file_unique_origin": true, 115 | "ui.key.menuAccessKeyFocuses": false 116 | }, 117 | "PromptForDownloadLocation": false, 118 | "SanitizeOnShutdown": true 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /internal/proxy/lobby.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/m1k1o/neko-rooms/internal/utils" 7 | ) 8 | 9 | func roomWait(w http.ResponseWriter, r *http.Request) { 10 | w.Write([]byte(``)) 41 | } 42 | 43 | func RoomNotFound(w http.ResponseWriter, r *http.Request, waitEnabled bool) { 44 | utils.Swal2Response(w, ` 45 |
46 |
47 |
X
48 |
49 |

Room not found!

50 |
51 |
52 |
The room you are trying to join does not exist.
53 |
You can wait on this page until it will be created.
54 |
55 |
56 | 57 |
58 | `) 59 | 60 | if waitEnabled { 61 | roomWait(w, r) 62 | } else { 63 | w.Write([]byte(``)) 64 | } 65 | } 66 | 67 | func RoomNotRunning(w http.ResponseWriter, r *http.Request, waitEnabled bool) { 68 | utils.Swal2Response(w, ` 69 |
70 |
71 |
!
72 |
73 |

Room is not running!

74 |
75 |
76 |
The room you are trying to join is not running.
77 |
You can wait on this page until it will be started.
78 |
79 |
80 | 81 |
82 | `) 83 | 84 | if waitEnabled { 85 | roomWait(w, r) 86 | } else { 87 | w.Write([]byte(``)) 88 | } 89 | } 90 | func RoomPaused(w http.ResponseWriter, r *http.Request, waitEnabled bool) { 91 | utils.Swal2Response(w, ` 92 |
93 |
94 |
!
95 |
96 |

Room is paused!

97 |
98 |
99 |
The room you are trying to join is paused.
100 |
You can wait on this page until it will be unpaused.
101 |
102 |
103 | 104 | 105 |
106 | `) 107 | 108 | if waitEnabled { 109 | roomWait(w, r) 110 | } else { 111 | w.Write([]byte(``)) 112 | } 113 | } 114 | 115 | func RoomNotReady(w http.ResponseWriter, r *http.Request, waitEnabled bool) { 116 | utils.Swal2Response(w, ` 117 | 118 | 119 |
120 |
121 |
i
122 |
123 |

Room is not ready, yet!

124 |
125 |
126 |
Please wait, until this room is ready so you can join. This should happen any second now.
127 |
128 |
129 |
130 | 131 |
132 | `) 133 | 134 | if waitEnabled { 135 | roomWait(w, r) 136 | } else { 137 | w.Write([]byte(``)) 138 | } 139 | } 140 | 141 | func RoomReady(w http.ResponseWriter, r *http.Request) { 142 | utils.Swal2Response(w, ` 143 |
144 |
145 |
146 | 147 |
148 |
149 |
150 |

Room is ready!

151 |
152 |
153 |
Requested room is ready, you can join now.
154 |
Try to reload page.
155 |
156 |
157 | 158 |
159 |
160 | If you see this page after refresh,
it can mean misconfiguration on your side.
161 |
162 | `) 163 | } 164 | -------------------------------------------------------------------------------- /internal/pull/manager.go: -------------------------------------------------------------------------------- 1 | package pull 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "slices" 10 | "sync" 11 | "time" 12 | 13 | dockerTypes "github.com/docker/docker/api/types" 14 | "github.com/docker/docker/api/types/registry" 15 | dockerClient "github.com/docker/docker/client" 16 | "github.com/rs/zerolog" 17 | "github.com/rs/zerolog/log" 18 | 19 | "github.com/m1k1o/neko-rooms/internal/types" 20 | ) 21 | 22 | type PullManagerCtx struct { 23 | logger zerolog.Logger 24 | client *dockerClient.Client 25 | images []string 26 | 27 | mu sync.Mutex 28 | cancel func() 29 | status types.PullStatus 30 | layers map[string]int 31 | 32 | chansMu sync.Mutex 33 | chans []chan<- string 34 | } 35 | 36 | func New(client *dockerClient.Client, nekoImages []string) *PullManagerCtx { 37 | return &PullManagerCtx{ 38 | logger: log.With().Str("module", "pull").Logger(), 39 | client: client, 40 | images: nekoImages, 41 | } 42 | } 43 | 44 | func (manager *PullManagerCtx) tryInitialize(cancel func()) bool { 45 | manager.mu.Lock() 46 | defer manager.mu.Unlock() 47 | 48 | if manager.status.Active { 49 | cancel() 50 | return false 51 | } 52 | 53 | now := time.Now() 54 | manager.cancel = cancel 55 | 56 | manager.status = types.PullStatus{ 57 | Active: true, 58 | Started: &now, 59 | Layers: []types.PullLayer{}, 60 | Status: []string{}, 61 | } 62 | 63 | manager.layers = map[string]int{} 64 | 65 | return true 66 | } 67 | 68 | func (manager *PullManagerCtx) setDone() { 69 | manager.mu.Lock() 70 | defer manager.mu.Unlock() 71 | 72 | now := time.Now() 73 | manager.status.Active = false 74 | manager.status.Finished = &now 75 | } 76 | 77 | func (manager *PullManagerCtx) Start(request types.PullStart) error { 78 | if !slices.Contains(manager.images, request.NekoImage) { 79 | return fmt.Errorf("unknown neko image") 80 | } 81 | 82 | ctx, cancel := context.WithCancel(context.Background()) 83 | if !manager.tryInitialize(cancel) { 84 | return fmt.Errorf("pull is already in progess") 85 | } 86 | 87 | // handle registry auth 88 | var opts dockerTypes.ImagePullOptions 89 | if request.RegistryUser != "" && request.RegistryPass != "" { 90 | authConfig := registry.AuthConfig{ 91 | Username: request.RegistryUser, 92 | Password: request.RegistryPass, 93 | } 94 | 95 | encodedJSON, err := json.Marshal(authConfig) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | opts = dockerTypes.ImagePullOptions{ 101 | RegistryAuth: base64.URLEncoding.EncodeToString(encodedJSON), 102 | } 103 | } 104 | 105 | reader, err := manager.client.ImagePull(ctx, request.NekoImage, opts) 106 | 107 | if err != nil { 108 | manager.setDone() 109 | return err 110 | } 111 | 112 | go func() { 113 | scanner := bufio.NewScanner(reader) 114 | for scanner.Scan() { 115 | data := scanner.Bytes() 116 | manager.sendSSE(string(data)) 117 | 118 | layer := types.PullLayer{} 119 | if err := json.Unmarshal(data, &layer); err != nil { 120 | manager.status.Status = append( 121 | manager.status.Status, 122 | fmt.Sprintf("Error while parsing pull response: %s", err), 123 | ) 124 | continue 125 | } 126 | 127 | if layer.ProgressDetail != nil { 128 | // map layer id to slice index 129 | if index, ok := manager.layers[layer.ID]; ok { 130 | manager.status.Layers[index] = layer 131 | } else { 132 | manager.layers[layer.ID] = len(manager.layers) 133 | manager.status.Layers = append(manager.status.Layers, layer) 134 | } 135 | } else { 136 | manager.status.Status = append( 137 | manager.status.Status, 138 | layer.Status, 139 | ) 140 | } 141 | } 142 | 143 | if err := scanner.Err(); err != nil { 144 | manager.status.Status = append( 145 | manager.status.Status, 146 | fmt.Sprintf("Error while reading pull response: %s", err), 147 | ) 148 | } 149 | 150 | reader.Close() 151 | manager.setDone() 152 | }() 153 | 154 | return nil 155 | } 156 | 157 | func (manager *PullManagerCtx) Stop() error { 158 | manager.mu.Lock() 159 | defer manager.mu.Unlock() 160 | 161 | if !manager.status.Active { 162 | return fmt.Errorf("pull is not in progess") 163 | 164 | } 165 | 166 | manager.cancel() 167 | return nil 168 | } 169 | 170 | func (manager *PullManagerCtx) Status() types.PullStatus { 171 | manager.mu.Lock() 172 | defer manager.mu.Unlock() 173 | 174 | return manager.status 175 | } 176 | 177 | func (manager *PullManagerCtx) sendSSE(status string) { 178 | manager.chansMu.Lock() 179 | defer manager.chansMu.Unlock() 180 | 181 | for _, ch := range manager.chans { 182 | ch <- status 183 | } 184 | } 185 | 186 | func (manager *PullManagerCtx) Subscribe(ch chan<- string) func() { 187 | manager.chansMu.Lock() 188 | defer manager.chansMu.Unlock() 189 | 190 | // subscribe 191 | manager.chans = append(manager.chans, ch) 192 | 193 | // unsubscribe 194 | return func() { 195 | manager.chansMu.Lock() 196 | defer manager.chansMu.Unlock() 197 | 198 | for i, c := range manager.chans { 199 | if c == ch { 200 | manager.chans = append(manager.chans[:i], manager.chans[i+1:]...) 201 | break 202 | } 203 | } 204 | } 205 | } 206 | 207 | func (manager *PullManagerCtx) Shutdown() error { 208 | manager.chansMu.Lock() 209 | for _, ch := range manager.chans { 210 | close(ch) 211 | } 212 | manager.chansMu.Unlock() 213 | 214 | manager.mu.Lock() 215 | if manager.cancel != nil { 216 | manager.cancel() 217 | } 218 | manager.mu.Unlock() 219 | 220 | return nil 221 | } 222 | -------------------------------------------------------------------------------- /internal/room/containers.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "time" 9 | 10 | dockerTypes "github.com/docker/docker/api/types" 11 | "github.com/docker/docker/api/types/filters" 12 | dockerClient "github.com/docker/docker/client" 13 | 14 | "github.com/m1k1o/neko-rooms/internal/types" 15 | ) 16 | 17 | func (manager *RoomManagerCtx) containerToEntry(container dockerTypes.Container) (*types.RoomEntry, error) { 18 | labels, err := manager.extractLabels(container.Labels) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | roomId := container.ID[:12] 24 | 25 | entry := &types.RoomEntry{ 26 | ID: roomId, 27 | URL: labels.URL, 28 | Name: labels.Name, 29 | NekoImage: labels.NekoImage, 30 | IsOutdated: labels.NekoImage != container.Image, 31 | MaxConnections: labels.Epr.Max - labels.Epr.Min + 1, 32 | Running: container.State == "running", 33 | Paused: container.State == "paused", 34 | IsReady: manager.events.IsRoomReady(roomId) || strings.Contains(container.Status, "healthy"), 35 | Status: container.Status, 36 | Created: time.Unix(container.Created, 0), 37 | Labels: labels.UserDefined, 38 | 39 | ContainerLabels: container.Labels, 40 | } 41 | 42 | if labels.Mux { 43 | entry.MaxConnections = 0 44 | } 45 | 46 | return entry, nil 47 | } 48 | 49 | func (manager *RoomManagerCtx) listContainers(ctx context.Context, labels map[string]string) ([]dockerTypes.Container, error) { 50 | args := filters.NewArgs( 51 | filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", manager.config.InstanceName)), 52 | ) 53 | 54 | for key, val := range labels { 55 | args.Add("label", fmt.Sprintf("m1k1o.neko_rooms.x-%s=%s", key, val)) 56 | } 57 | 58 | return manager.client.ContainerList(ctx, dockerTypes.ContainerListOptions{ 59 | All: true, 60 | Filters: args, 61 | }) 62 | } 63 | 64 | func (manager *RoomManagerCtx) containerFilter(ctx context.Context, args filters.Args) (*dockerTypes.Container, error) { 65 | args.Add("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", manager.config.InstanceName)) 66 | 67 | containers, err := manager.client.ContainerList(ctx, dockerTypes.ContainerListOptions{ 68 | All: true, 69 | Filters: args, 70 | }) 71 | 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if len(containers) == 0 { 77 | return nil, types.ErrRoomNotFound 78 | } 79 | 80 | container := containers[0] 81 | return &container, nil 82 | } 83 | 84 | func (manager *RoomManagerCtx) containerById(ctx context.Context, id string) (*dockerTypes.Container, error) { 85 | return manager.containerFilter(ctx, filters.NewArgs( 86 | filters.Arg("id", id), 87 | )) 88 | } 89 | 90 | func (manager *RoomManagerCtx) containerByName(ctx context.Context, name string) (*dockerTypes.Container, error) { 91 | return manager.containerFilter(ctx, filters.NewArgs( 92 | filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.name=%s", name)), 93 | )) 94 | } 95 | 96 | func (manager *RoomManagerCtx) inspectContainer(ctx context.Context, id string) (*dockerTypes.ContainerJSON, error) { 97 | container, err := manager.client.ContainerInspect(ctx, id) 98 | if err != nil { 99 | if dockerClient.IsErrNotFound(err) { 100 | return nil, types.ErrRoomNotFound 101 | } 102 | return nil, err 103 | } 104 | 105 | val, ok := container.Config.Labels["m1k1o.neko_rooms.instance"] 106 | if !ok || val != manager.config.InstanceName { 107 | return nil, fmt.Errorf("this container does not belong to neko_rooms") 108 | } 109 | 110 | return &container, nil 111 | } 112 | 113 | func (manager *RoomManagerCtx) containerExec(ctx context.Context, id string, cmd []string) (string, error) { 114 | exec, err := manager.client.ContainerExecCreate(ctx, id, dockerTypes.ExecConfig{ 115 | AttachStderr: true, 116 | AttachStdin: true, 117 | AttachStdout: true, 118 | Cmd: cmd, 119 | Tty: true, 120 | Detach: false, 121 | }) 122 | if err != nil { 123 | if dockerClient.IsErrNotFound(err) { 124 | return "", types.ErrRoomNotFound 125 | } 126 | return "", err 127 | } 128 | 129 | conn, err := manager.client.ContainerExecAttach(ctx, exec.ID, dockerTypes.ExecStartCheck{ 130 | Detach: false, 131 | Tty: true, 132 | }) 133 | if err != nil { 134 | return "", err 135 | } 136 | defer conn.Close() 137 | 138 | data, err := io.ReadAll(conn.Reader) 139 | return string(data), err 140 | } 141 | -------------------------------------------------------------------------------- /internal/room/labels.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/m1k1o/neko-rooms/internal/types" 10 | ) 11 | 12 | var labelRegex = regexp.MustCompile(`^[a-z0-9.-]+$`) 13 | 14 | type RoomLabels struct { 15 | Name string 16 | URL string 17 | Mux bool 18 | Epr EprPorts 19 | 20 | NekoImage string 21 | ApiVersion int 22 | 23 | BrowserPolicy *BrowserPolicyLabels 24 | UserDefined map[string]string 25 | } 26 | 27 | type BrowserPolicyLabels struct { 28 | Type types.BrowserPolicyType 29 | Path string 30 | } 31 | 32 | func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLabels, error) { 33 | name, ok := labels["m1k1o.neko_rooms.name"] 34 | if !ok { 35 | return nil, fmt.Errorf("damaged container labels: name not found") 36 | } 37 | 38 | url, ok := labels["m1k1o.neko_rooms.url"] 39 | if !ok { 40 | // TODO: It should be always available. 41 | url = manager.config.GetRoomUrl(name) 42 | //return nil, fmt.Errorf("damaged container labels: url not found") 43 | } 44 | 45 | var mux bool 46 | var epr EprPorts 47 | 48 | muxStr, ok := labels["m1k1o.neko_rooms.mux"] 49 | if ok { 50 | muxPort, err := strconv.ParseUint(muxStr, 10, 16) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | mux = true 56 | epr = EprPorts{ 57 | Min: uint16(muxPort), 58 | Max: uint16(muxPort), 59 | } 60 | } else { 61 | eprMinStr, ok := labels["m1k1o.neko_rooms.epr.min"] 62 | if !ok { 63 | return nil, fmt.Errorf("damaged container labels: epr.min not found") 64 | } 65 | 66 | eprMin, err := strconv.ParseUint(eprMinStr, 10, 16) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | eprMaxStr, ok := labels["m1k1o.neko_rooms.epr.max"] 72 | if !ok { 73 | return nil, fmt.Errorf("damaged container labels: epr.max not found") 74 | } 75 | 76 | eprMax, err := strconv.ParseUint(eprMaxStr, 10, 16) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | mux = false 82 | epr = EprPorts{ 83 | Min: uint16(eprMin), 84 | Max: uint16(eprMax), 85 | } 86 | } 87 | 88 | nekoImage, ok := labels["m1k1o.neko_rooms.neko_image"] 89 | if !ok { 90 | return nil, fmt.Errorf("damaged container labels: neko_image not found") 91 | } 92 | 93 | apiVersion := 2 // default, prior to api versioning 94 | apiVersionStr, ok := labels["m1k1o.neko_rooms.api_version"] 95 | if ok { 96 | var err error 97 | apiVersion, err = strconv.Atoi(apiVersionStr) 98 | if err != nil { 99 | return nil, err 100 | } 101 | } 102 | 103 | var browserPolicy *BrowserPolicyLabels 104 | if val, ok := labels["m1k1o.neko_rooms.browser_policy"]; ok && val == "true" { 105 | policyType, ok := labels["m1k1o.neko_rooms.browser_policy.type"] 106 | if !ok { 107 | return nil, fmt.Errorf("damaged container labels: browser_policy.type not found") 108 | } 109 | 110 | policyPath, ok := labels["m1k1o.neko_rooms.browser_policy.path"] 111 | if !ok { 112 | return nil, fmt.Errorf("damaged container labels: browser_policy.path not found") 113 | } 114 | 115 | browserPolicy = &BrowserPolicyLabels{ 116 | Type: types.BrowserPolicyType(policyType), 117 | Path: policyPath, 118 | } 119 | } 120 | 121 | // extract user defined labels 122 | userDefined := map[string]string{} 123 | for key, val := range labels { 124 | if strings.HasPrefix(key, "m1k1o.neko_rooms.x-") { 125 | userDefined[strings.TrimPrefix(key, "m1k1o.neko_rooms.x-")] = val 126 | } 127 | } 128 | 129 | return &RoomLabels{ 130 | Name: name, 131 | URL: url, 132 | Mux: mux, 133 | Epr: epr, 134 | 135 | NekoImage: nekoImage, 136 | ApiVersion: apiVersion, 137 | 138 | BrowserPolicy: browserPolicy, 139 | UserDefined: userDefined, 140 | }, nil 141 | } 142 | 143 | func (manager *RoomManagerCtx) serializeLabels(labels RoomLabels) map[string]string { 144 | labelsMap := map[string]string{ 145 | "m1k1o.neko_rooms.name": labels.Name, 146 | "m1k1o.neko_rooms.url": manager.config.GetRoomUrl(labels.Name), 147 | "m1k1o.neko_rooms.instance": manager.config.InstanceName, 148 | "m1k1o.neko_rooms.neko_image": labels.NekoImage, 149 | } 150 | 151 | // api version 2 is currently default 152 | if labels.ApiVersion != 2 { 153 | labelsMap["m1k1o.neko_rooms.api_version"] = fmt.Sprintf("%d", labels.ApiVersion) 154 | } 155 | 156 | if labels.Mux && labels.Epr.Min == labels.Epr.Max { 157 | labelsMap["m1k1o.neko_rooms.mux"] = fmt.Sprintf("%d", labels.Epr.Min) 158 | } else { 159 | labelsMap["m1k1o.neko_rooms.epr.min"] = fmt.Sprintf("%d", labels.Epr.Min) 160 | labelsMap["m1k1o.neko_rooms.epr.max"] = fmt.Sprintf("%d", labels.Epr.Max) 161 | } 162 | 163 | if labels.BrowserPolicy != nil { 164 | labelsMap["m1k1o.neko_rooms.browser_policy"] = "true" 165 | labelsMap["m1k1o.neko_rooms.browser_policy.type"] = string(labels.BrowserPolicy.Type) 166 | labelsMap["m1k1o.neko_rooms.browser_policy.path"] = labels.BrowserPolicy.Path 167 | } 168 | 169 | for key, val := range labels.UserDefined { 170 | // to lowercase 171 | key = strings.ToLower(key) 172 | 173 | labelsMap[fmt.Sprintf("m1k1o.neko_rooms.x-%s", key)] = val 174 | } 175 | 176 | return labelsMap 177 | } 178 | 179 | func CheckLabelKey(name string) bool { 180 | return labelRegex.MatchString(name) 181 | } 182 | -------------------------------------------------------------------------------- /internal/room/ports.go: -------------------------------------------------------------------------------- 1 | package room 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | ) 8 | 9 | type EprPorts struct { 10 | Min uint16 11 | Max uint16 12 | } 13 | 14 | func (manager *RoomManagerCtx) allocatePorts(ctx context.Context, sum uint16) (EprPorts, error) { 15 | if sum < 1 { 16 | return EprPorts{}, fmt.Errorf("unable to allocate 0 ports") 17 | } 18 | 19 | min := manager.config.EprMin 20 | max := manager.config.EprMax 21 | 22 | epr := EprPorts{ 23 | Min: min, 24 | Max: min + sum - 1, 25 | } 26 | 27 | ports, err := manager.getUsedPorts(ctx) 28 | if err != nil { 29 | return epr, err 30 | } 31 | 32 | for _, port := range ports { 33 | if (epr.Min >= port.Min && epr.Min <= port.Max) || 34 | (epr.Max >= port.Min && epr.Max <= port.Max) { 35 | epr.Min = port.Max + 1 36 | epr.Max = port.Max + sum 37 | } 38 | } 39 | 40 | if epr.Min > max || epr.Max > max { 41 | return epr, fmt.Errorf("unable to allocate ports: not enough ports") 42 | } 43 | 44 | return epr, nil 45 | } 46 | 47 | func (manager *RoomManagerCtx) getUsedPorts(ctx context.Context) ([]EprPorts, error) { 48 | containers, err := manager.listContainers(ctx, nil) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | result := []EprPorts{} 54 | for _, container := range containers { 55 | labels, err := manager.extractLabels(container.Labels) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | result = append(result, labels.Epr) 61 | } 62 | 63 | sort.SliceStable(result, func(i, j int) bool { 64 | return result[i].Min < result[j].Min 65 | }) 66 | 67 | return result, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/server/logger.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/go-chi/chi/v5/middleware" 9 | "github.com/rs/zerolog" 10 | ) 11 | 12 | type logformatter struct { 13 | logger zerolog.Logger 14 | } 15 | 16 | func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry { 17 | req := map[string]any{} 18 | 19 | if reqID := middleware.GetReqID(r.Context()); reqID != "" { 20 | req["id"] = reqID 21 | } 22 | 23 | scheme := "http" 24 | if r.TLS != nil { 25 | scheme = "https" 26 | } 27 | 28 | req["scheme"] = scheme 29 | req["proto"] = r.Proto 30 | req["method"] = r.Method 31 | req["remote"] = r.RemoteAddr 32 | req["agent"] = r.UserAgent() 33 | req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) 34 | 35 | fields := map[string]any{} 36 | fields["req"] = req 37 | 38 | return &logentry{ 39 | fields: fields, 40 | logger: l.logger, 41 | } 42 | } 43 | 44 | type logentry struct { 45 | logger zerolog.Logger 46 | fields map[string]any 47 | errors []map[string]any 48 | } 49 | 50 | func (e *logentry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) { 51 | res := map[string]any{} 52 | res["time"] = time.Now().UTC().Format(time.RFC1123) 53 | res["status"] = status 54 | res["bytes"] = bytes 55 | res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0 56 | 57 | e.fields["res"] = res 58 | e.fields["module"] = "http" 59 | 60 | if len(e.errors) > 0 { 61 | e.fields["errors"] = e.errors 62 | e.logger.Error().Fields(e.fields).Msgf("request failed (%d)", status) 63 | } else { 64 | e.logger.Debug().Fields(e.fields).Msgf("request complete (%d)", status) 65 | } 66 | } 67 | 68 | func (e *logentry) Panic(v any, stack []byte) { 69 | err := map[string]any{} 70 | err["message"] = fmt.Sprintf("%+v", v) 71 | err["stack"] = string(stack) 72 | 73 | e.errors = append(e.errors, err) 74 | } 75 | -------------------------------------------------------------------------------- /internal/types/api.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | ) 6 | 7 | type ApiManager interface { 8 | Mount(r chi.Router) 9 | } 10 | -------------------------------------------------------------------------------- /internal/types/policies.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type BrowserPolicyType string 4 | 5 | const ( 6 | ChromiumBrowserPolicy BrowserPolicyType = "chromium" 7 | FirefoxBrowserPolicy BrowserPolicyType = "firefox" 8 | ) 9 | 10 | type BrowserPolicy struct { 11 | Type BrowserPolicyType `json:"type"` 12 | Path string `json:"path"` 13 | Content BrowserPolicyContent `json:"content"` 14 | } 15 | 16 | type BrowserPolicyContent struct { 17 | Extensions []BrowserPolicyExtension `json:"extensions"` 18 | DeveloperTools bool `json:"developer_tools"` 19 | PersistentData bool `json:"persistent_data"` 20 | } 21 | 22 | type BrowserPolicyExtension struct { 23 | ID string `json:"id"` 24 | URL string `json:"url"` 25 | } 26 | -------------------------------------------------------------------------------- /internal/types/proxy.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "net/http" 4 | 5 | type ProxyManager interface { 6 | ServeHTTP(w http.ResponseWriter, r *http.Request) 7 | Shutdown() error 8 | } 9 | -------------------------------------------------------------------------------- /internal/types/pull.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | type PullStart struct { 6 | NekoImage string `json:"neko_image"` 7 | RegistryUser string `json:"registry_user"` 8 | RegistryPass string `json:"registry_pass"` 9 | } 10 | 11 | type PullLayer struct { 12 | Status string `json:"status"` 13 | ProgressDetail *struct { 14 | Current int `json:"current"` 15 | Total int `json:"total"` 16 | } `json:"progressDetail"` 17 | Progress string `json:"progress"` 18 | ID string `json:"id"` 19 | } 20 | 21 | type PullStatus struct { 22 | Active bool `json:"active"` 23 | Started *time.Time `json:"started"` 24 | Layers []PullLayer `json:"layers"` 25 | Status []string `json:"status"` 26 | Finished *time.Time `json:"finished"` 27 | } 28 | 29 | type PullManager interface { 30 | Start(request PullStart) error 31 | Stop() error 32 | Status() PullStatus 33 | Subscribe(ch chan<- string) func() 34 | } 35 | -------------------------------------------------------------------------------- /internal/types/room.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/m1k1o/neko-rooms/internal/config" 9 | ) 10 | 11 | type RoomsConfig struct { 12 | Connections uint16 `json:"connections"` 13 | NekoImages []string `json:"neko_images"` 14 | StorageEnabled bool `json:"storage_enabled"` 15 | UsesMux bool `json:"uses_mux"` 16 | } 17 | 18 | type RoomEntry struct { 19 | ID string `json:"id"` 20 | URL string `json:"url"` 21 | Name string `json:"name"` 22 | NekoImage string `json:"neko_image"` 23 | IsOutdated bool `json:"is_outdated"` 24 | MaxConnections uint16 `json:"max_connections"` // 0 when using mux 25 | Running bool `json:"running"` 26 | Paused bool `json:"paused"` 27 | IsReady bool `json:"is_ready"` 28 | Status string `json:"status"` 29 | Created time.Time `json:"created"` 30 | Labels map[string]string `json:"labels,omitempty"` 31 | 32 | ContainerLabels map[string]string `json:"-"` // for internal use 33 | } 34 | 35 | type MountType string 36 | 37 | const ( 38 | MountPrivate MountType = "private" 39 | MountTemplate MountType = "template" 40 | MountProtected MountType = "protected" 41 | MountPublic MountType = "public" 42 | ) 43 | 44 | type RoomMount struct { 45 | Type MountType `json:"type"` 46 | HostPath string `json:"host_path"` 47 | ContainerPath string `json:"container_path"` 48 | } 49 | 50 | type RoomResources struct { 51 | CPUShares int64 `json:"cpu_shares"` // relative weight vs. other containers 52 | NanoCPUs int64 `json:"nano_cpus"` // in units of 10^-9 CPUs 53 | ShmSize int64 `json:"shm_size"` // in bytes 54 | Memory int64 `json:"memory"` // in bytes 55 | Gpus []string `json:"gpus"` // gpu opts 56 | Devices []string `json:"devices"` 57 | } 58 | 59 | type RoomSettings struct { 60 | ApiVersion int `json:"api_version"` 61 | 62 | Name string `json:"name"` 63 | NekoImage string `json:"neko_image"` 64 | MaxConnections uint16 `json:"max_connections"` // 0 when using mux 65 | 66 | ControlProtection bool `json:"control_protection"` 67 | ImplicitControl bool `json:"implicit_control"` 68 | 69 | UserPass string `json:"user_pass"` 70 | AdminPass string `json:"admin_pass"` 71 | 72 | Screen string `json:"screen"` 73 | VideoCodec string `json:"video_codec,omitempty"` 74 | VideoBitrate int `json:"video_bitrate,omitempty"` 75 | VideoPipeline string `json:"video_pipeline,omitempty"` 76 | VideoMaxFPS int `json:"video_max_fps"` 77 | 78 | AudioCodec string `json:"audio_codec,omitempty"` 79 | AudioBitrate int `json:"audio_bitrate,omitempty"` 80 | AudioPipeline string `json:"audio_pipeline,omitempty"` 81 | 82 | BroadcastPipeline string `json:"broadcast_pipeline,omitempty"` 83 | 84 | Envs map[string]string `json:"envs"` 85 | Labels map[string]string `json:"labels"` 86 | Mounts []RoomMount `json:"mounts"` 87 | Resources RoomResources `json:"resources"` 88 | 89 | Hostname string `json:"hostname,omitempty"` 90 | DNS []string `json:"dns,omitempty"` 91 | 92 | BrowserPolicy *BrowserPolicy `json:"browser_policy,omitempty"` 93 | } 94 | 95 | func (settings *RoomSettings) ToEnv(config *config.Room, ports PortSettings) ([]string, error) { 96 | switch settings.ApiVersion { 97 | case 2: 98 | return settings.toEnvV2(config, ports), nil 99 | case 3: 100 | return settings.toEnvV3(config, ports), nil 101 | default: 102 | return nil, fmt.Errorf("unsupported API version: %d", settings.ApiVersion) 103 | } 104 | } 105 | 106 | func (settings *RoomSettings) FromEnv(apiVersion int, envs []string) error { 107 | switch apiVersion { 108 | case 2: 109 | return settings.fromEnvV2(envs) 110 | case 3: 111 | return settings.fromEnvV3(envs) 112 | default: 113 | return fmt.Errorf("unsupported API version: %d", apiVersion) 114 | } 115 | } 116 | 117 | type PortSettings struct { 118 | FrontendPort uint16 119 | EprMin, EprMax uint16 120 | } 121 | 122 | type RoomStats struct { 123 | Connections uint32 `json:"connections"` 124 | Host string `json:"host"` 125 | Members []*RoomMember `json:"members"` 126 | 127 | Banned map[string]string `json:"banned"` // IP -> session ID (that banned it) 128 | Locked map[string]string `json:"locked"` // resource name -> session ID (that locked it) 129 | 130 | ServerStartedAt time.Time `json:"server_started_at"` 131 | LastAdminLeftAt *time.Time `json:"last_admin_left_at"` 132 | LastUserLeftAt *time.Time `json:"last_user_left_at"` 133 | 134 | ControlProtection bool `json:"control_protection"` 135 | ImplicitControl bool `json:"implicit_control"` 136 | } 137 | 138 | type RoomMember struct { 139 | ID string `json:"id"` 140 | Name string `json:"displayname"` 141 | Admin bool `json:"admin"` 142 | Muted bool `json:"muted"` 143 | } 144 | 145 | type RoomEventAction string 146 | 147 | const ( 148 | RoomEventCreated RoomEventAction = "created" 149 | RoomEventStarted RoomEventAction = "started" 150 | RoomEventReady RoomEventAction = "ready" 151 | RoomEventStopped RoomEventAction = "stopped" 152 | RoomEventDestroyed RoomEventAction = "destroyed" 153 | RoomEventPaused RoomEventAction = "paused" 154 | ) 155 | 156 | type RoomEvent struct { 157 | ID string `json:"id"` 158 | Action RoomEventAction `json:"action"` 159 | 160 | ContainerLabels map[string]string `json:"-"` // for internal use 161 | } 162 | 163 | var ErrRoomNotFound = fmt.Errorf("room not found") 164 | 165 | type RoomManager interface { 166 | Config() RoomsConfig 167 | List(ctx context.Context, labels map[string]string) ([]RoomEntry, error) 168 | ExportAsDockerCompose(ctx context.Context) ([]byte, error) 169 | 170 | Create(ctx context.Context, settings RoomSettings) (string, error) 171 | GetEntry(ctx context.Context, id string) (*RoomEntry, error) 172 | GetEntryByName(ctx context.Context, name string) (*RoomEntry, error) 173 | GetSettings(ctx context.Context, id string) (*RoomSettings, error) 174 | GetStats(ctx context.Context, id string) (*RoomStats, error) 175 | Remove(ctx context.Context, id string) error 176 | 177 | Start(ctx context.Context, id string) error 178 | Stop(ctx context.Context, id string) error 179 | Restart(ctx context.Context, id string) error 180 | Pause(ctx context.Context, id string) error 181 | 182 | EventsLoopStart() 183 | EventsLoopStop() error 184 | Events(ctx context.Context) (<-chan RoomEvent, <-chan error) 185 | } 186 | -------------------------------------------------------------------------------- /internal/types/room_api_v2.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/m1k1o/neko-rooms/internal/config" 10 | ) 11 | 12 | // 13 | // m1k1o/neko v2 envs API 14 | // 15 | 16 | var blacklistedEnvsV2 = []string{ 17 | // ignore bunch of default envs 18 | "DEBIAN_FRONTEND", 19 | "PULSE_SERVER", 20 | "XDG_RUNTIME_DIR", 21 | "DISPLAY", 22 | "USER", 23 | "PATH", 24 | 25 | // ignore bunch of envs managed by neko-rooms 26 | "NEKO_BIND", 27 | "NEKO_EPR", 28 | "NEKO_UDPMUX", 29 | "NEKO_TCPMUX", 30 | "NEKO_NAT1TO1", 31 | "NEKO_ICELITE", 32 | "NEKO_PROXY", 33 | } 34 | 35 | func (settings *RoomSettings) toEnvV2(config *config.Room, ports PortSettings) []string { 36 | env := []string{ 37 | fmt.Sprintf("NEKO_BIND=:%d", ports.FrontendPort), 38 | "NEKO_ICELITE=true", 39 | "NEKO_PROXY=true", 40 | 41 | // from settings 42 | fmt.Sprintf("NEKO_PASSWORD=%s", settings.UserPass), 43 | fmt.Sprintf("NEKO_PASSWORD_ADMIN=%s", settings.AdminPass), 44 | fmt.Sprintf("NEKO_SCREEN=%s", settings.Screen), 45 | fmt.Sprintf("NEKO_MAX_FPS=%d", settings.VideoMaxFPS), 46 | } 47 | 48 | if config.Mux { 49 | env = append(env, 50 | fmt.Sprintf("NEKO_UDPMUX=%d", ports.EprMin), 51 | fmt.Sprintf("NEKO_TCPMUX=%d", ports.EprMin), 52 | ) 53 | } else { 54 | env = append(env, 55 | fmt.Sprintf("NEKO_EPR=%d-%d", ports.EprMin, ports.EprMax), 56 | ) 57 | } 58 | 59 | // optional nat mapping 60 | if len(config.NAT1To1IPs) > 0 { 61 | env = append(env, fmt.Sprintf("NEKO_NAT1TO1=%s", strings.Join(config.NAT1To1IPs, ","))) 62 | } 63 | 64 | if settings.ControlProtection { 65 | env = append(env, "NEKO_CONTROL_PROTECTION=true") 66 | } 67 | 68 | if settings.ImplicitControl { 69 | env = append(env, "NEKO_IMPLICIT_CONTROL=true") 70 | } 71 | 72 | if settings.VideoCodec != "VP8" { // VP8 is default 73 | env = append(env, fmt.Sprintf("NEKO_VIDEO_CODEC=%s", strings.ToLower(settings.VideoCodec))) 74 | } 75 | 76 | if settings.VideoBitrate != 0 { 77 | env = append(env, fmt.Sprintf("NEKO_VIDEO_BITRATE=%d", settings.VideoBitrate)) 78 | } 79 | 80 | if settings.VideoPipeline != "" { 81 | env = append(env, fmt.Sprintf("NEKO_VIDEO=%s", settings.VideoPipeline)) 82 | } 83 | 84 | if settings.AudioCodec != "OPUS" { // OPUS is default 85 | env = append(env, fmt.Sprintf("NEKO_AUDIO_CODEC=%s", strings.ToLower(settings.AudioCodec))) 86 | } 87 | 88 | if settings.AudioBitrate != 0 { 89 | env = append(env, fmt.Sprintf("NEKO_AUDIO_BITRATE=%d", settings.AudioBitrate)) 90 | } 91 | 92 | if settings.AudioPipeline != "" { 93 | env = append(env, fmt.Sprintf("NEKO_AUDIO=%s", settings.AudioPipeline)) 94 | } 95 | 96 | if settings.BroadcastPipeline != "" { 97 | env = append(env, fmt.Sprintf("NEKO_BROADCAST_PIPELINE=%s", settings.BroadcastPipeline)) 98 | } 99 | 100 | for key, val := range settings.Envs { 101 | if !slices.Contains(blacklistedEnvsV2, key) { 102 | env = append(env, fmt.Sprintf("%s=%s", key, val)) 103 | } 104 | } 105 | 106 | return env 107 | } 108 | 109 | func (settings *RoomSettings) fromEnvV2(envs []string) error { 110 | settings.Envs = map[string]string{} 111 | settings.VideoCodec = "VP8" // default 112 | settings.AudioCodec = "OPUS" // default 113 | 114 | var err error 115 | for _, env := range envs { 116 | r := strings.SplitN(env, "=", 2) 117 | key, val := r[0], r[1] 118 | 119 | switch key { 120 | case "NEKO_PASSWORD": 121 | settings.UserPass = val 122 | case "NEKO_PASSWORD_ADMIN": 123 | settings.AdminPass = val 124 | case "NEKO_CONTROL_PROTECTION": 125 | settings.ControlProtection, err = strconv.ParseBool(val) 126 | case "NEKO_IMPLICIT_CONTROL": 127 | settings.ImplicitControl, err = strconv.ParseBool(val) 128 | case "NEKO_SCREEN": 129 | settings.Screen = val 130 | case "NEKO_MAX_FPS": 131 | settings.VideoMaxFPS, err = strconv.Atoi(val) 132 | case "NEKO_BROADCAST_PIPELINE": 133 | settings.BroadcastPipeline = val 134 | case "NEKO_VIDEO_CODEC": 135 | settings.VideoCodec = strings.ToUpper(val) 136 | case "NEKO_VIDEO_BITRATE": 137 | settings.VideoBitrate, err = strconv.Atoi(val) 138 | case "NEKO_VIDEO": 139 | settings.VideoPipeline = val 140 | case "NEKO_AUDIO_CODEC": 141 | settings.VideoCodec = strings.ToUpper(val) 142 | case "NEKO_AUDIO_BITRATE": 143 | settings.AudioBitrate, err = strconv.Atoi(val) 144 | case "NEKO_AUDIO": 145 | settings.AudioPipeline = val 146 | default: 147 | if !slices.Contains(blacklistedEnvsV2, key) { 148 | settings.Envs[key] = val 149 | } 150 | } 151 | 152 | if err != nil { 153 | return err 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /internal/types/room_api_v3.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/m1k1o/neko-rooms/internal/config" 10 | ) 11 | 12 | // 13 | // demodesk/neko v3 envs API 14 | // 15 | 16 | var blacklistedEnvsV3 = []string{ 17 | // ignore bunch of default envs 18 | "DEBIAN_FRONTEND", 19 | "PULSE_SERVER", 20 | "XDG_RUNTIME_DIR", 21 | "DISPLAY", 22 | "USER", 23 | "PATH", 24 | 25 | // ignore bunch of envs managed by neko 26 | "NEKO_PLUGINS_ENABLED", 27 | "NEKO_PLUGINS_DIR", 28 | 29 | // ignore bunch of envs managed by neko-rooms 30 | "NEKO_SERVER_BIND", 31 | "NEKO_SERVER_PROXY", 32 | "NEKO_SESSION_API_TOKEN", 33 | "NEKO_MEMBER_PROVIDER", 34 | "NEKO_WEBRTC_EPR", 35 | "NEKO_WEBRTC_UDPMUX", 36 | "NEKO_WEBRTC_TCPMUX", 37 | "NEKO_WEBRTC_NAT1TO1", 38 | "NEKO_WEBRTC_ICELITE", 39 | } 40 | 41 | func (settings *RoomSettings) toEnvV3(config *config.Room, ports PortSettings) []string { 42 | env := []string{ 43 | fmt.Sprintf("NEKO_SERVER_BIND=:%d", ports.FrontendPort), 44 | "NEKO_WEBRTC_ICELITE=true", 45 | "NEKO_SERVER_PROXY=true", 46 | 47 | // from settings 48 | "NEKO_MEMBER_PROVIDER=multiuser", 49 | fmt.Sprintf("NEKO_MEMBER_MULTIUSER_USER_PASSWORD=%s", settings.UserPass), 50 | fmt.Sprintf("NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=%s", settings.AdminPass), 51 | fmt.Sprintf("NEKO_SESSION_API_TOKEN=%s", settings.AdminPass), // TODO: should be random and saved somewhere 52 | fmt.Sprintf("NEKO_DESKTOP_SCREEN=%s", settings.Screen), 53 | //fmt.Sprintf("NEKO_MAX_FPS=%d", settings.VideoMaxFPS), // TODO: not supported yet 54 | } 55 | 56 | if config.Mux { 57 | env = append(env, 58 | fmt.Sprintf("NEKO_WEBRTC_UDPMUX=%d", ports.EprMin), 59 | fmt.Sprintf("NEKO_WEBRTC_TCPMUX=%d", ports.EprMin), 60 | ) 61 | } else { 62 | env = append(env, 63 | fmt.Sprintf("NEKO_WEBRTC_EPR=%d-%d", ports.EprMin, ports.EprMax), 64 | ) 65 | } 66 | 67 | // optional nat mapping 68 | if len(config.NAT1To1IPs) > 0 { 69 | env = append(env, fmt.Sprintf("NEKO_WEBRTC_NAT1TO1=%s", strings.Join(config.NAT1To1IPs, ","))) 70 | } 71 | 72 | if settings.ControlProtection { 73 | env = append(env, "NEKO_SESSION_CONTROL_PROTECTION=true") 74 | } 75 | 76 | // implicit control - enabled by default but in legacy mode disabled by default 77 | // so we need to set it explicitly until legacy mode is removed 78 | if !settings.ImplicitControl { 79 | env = append(env, "NEKO_SESSION_IMPLICIT_HOSTING=false") 80 | } else { 81 | env = append(env, "NEKO_SESSION_IMPLICIT_HOSTING=true") 82 | } 83 | 84 | if settings.VideoCodec != "VP8" { // VP8 is default 85 | env = append(env, fmt.Sprintf("NEKO_CAPTURE_VIDEO_CODEC=%s", strings.ToLower(settings.VideoCodec))) 86 | } 87 | 88 | //if settings.VideoBitrate != 0 { 89 | // env = append(env, fmt.Sprintf("NEKO_VIDEO_BITRATE=%d", settings.VideoBitrate)) // TODO: not supported yet 90 | //} 91 | 92 | if settings.VideoPipeline != "" { 93 | env = append(env, fmt.Sprintf("NEKO_CAPTURE_VIDEO_PIPELINE=%s", settings.VideoPipeline)) // TOOD: allow simulcast pipelines 94 | } 95 | 96 | if settings.AudioCodec != "OPUS" { // OPUS is default 97 | env = append(env, fmt.Sprintf("NEKO_CAPTURE_AUDIO_CODEC=%s", strings.ToLower(settings.AudioCodec))) 98 | } 99 | 100 | //if settings.AudioBitrate != 0 { 101 | // env = append(env, fmt.Sprintf("NEKO_AUDIO_BITRATE=%d", settings.AudioBitrate)) // TODO: not supported yet 102 | //} 103 | 104 | if settings.AudioPipeline != "" { 105 | env = append(env, fmt.Sprintf("NEKO_CAPTURE_AUDIO_PIPELINE=%s", settings.AudioPipeline)) 106 | } 107 | 108 | if settings.BroadcastPipeline != "" { 109 | env = append(env, fmt.Sprintf("NEKO_CAPTURE_BROADCAST_PIPELINE=%s", settings.BroadcastPipeline)) 110 | } 111 | 112 | for key, val := range settings.Envs { 113 | if !slices.Contains(blacklistedEnvsV3, key) { 114 | env = append(env, fmt.Sprintf("%s=%s", key, val)) 115 | } 116 | } 117 | 118 | return env 119 | } 120 | 121 | func (settings *RoomSettings) fromEnvV3(envs []string) error { 122 | settings.Envs = map[string]string{} 123 | // enabled implicit control by default 124 | settings.ImplicitControl = true 125 | settings.VideoCodec = "VP8" // default 126 | settings.AudioCodec = "OPUS" // default 127 | 128 | var err error 129 | for _, env := range envs { 130 | r := strings.SplitN(env, "=", 2) 131 | key, val := r[0], r[1] 132 | 133 | switch key { 134 | case "NEKO_MEMBER_MULTIUSER_USER_PASSWORD": 135 | settings.UserPass = val 136 | case "NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD": 137 | settings.AdminPass = val 138 | case "NEKO_SESSION_CONTROL_PROTECTION": 139 | settings.ControlProtection, err = strconv.ParseBool(val) 140 | case "NEKO_SESSION_IMPLICIT_HOSTING": 141 | settings.ImplicitControl, err = strconv.ParseBool(val) 142 | case "NEKO_DESKTOP_SCREEN": 143 | settings.Screen = val 144 | //case "NEKO_MAX_FPS": // TODO: not supported yet 145 | // settings.VideoMaxFPS, err = strconv.Atoi(val) 146 | case "NEKO_CAPTURE_BROADCAST_PIPELINE": 147 | settings.BroadcastPipeline = val 148 | case "NEKO_CAPTURE_VIDEO_CODEC": 149 | settings.VideoCodec = strings.ToUpper(val) 150 | //case "NEKO_VIDEO_BITRATE": // TODO: not supported yet 151 | // settings.VideoBitrate, err = strconv.Atoi(val) 152 | case "NEKO_CAPTURE_VIDEO_PIPELINE": // TOOD: allow simulcast pipelines 153 | settings.VideoPipeline = val 154 | case "NEKO_CAPTURE_AUDIO_CODEC": 155 | settings.AudioCodec = strings.ToUpper(val) 156 | //case "NEKO_AUDIO_BITRATE": // TODO: not supported yet 157 | // settings.AudioBitrate, err = strconv.Atoi(val) 158 | case "NEKO_CAPTURE_AUDIO_PIPELINE": 159 | settings.AudioPipeline = val 160 | default: 161 | if !slices.Contains(blacklistedEnvsV3, key) { 162 | settings.Envs[key] = val 163 | } 164 | } 165 | 166 | if err != nil { 167 | return err 168 | } 169 | } 170 | 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /internal/utils/color.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | const ( 9 | char = "&" 10 | ) 11 | 12 | // Colors: http://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html 13 | var re = regexp.MustCompile(char + `(?m)([0-9]{1,2};[0-9]{1,2}|[0-9]{1,2})`) 14 | 15 | func Color(str string) string { 16 | result := "" 17 | lastIndex := 0 18 | 19 | for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { 20 | groups := []string{} 21 | for i := 0; i < len(v); i += 2 { 22 | groups = append(groups, str[v[i]:v[i+1]]) 23 | } 24 | 25 | result += str[lastIndex:v[0]] + "\033[" + groups[1] + "m" 26 | lastIndex = v[1] 27 | } 28 | 29 | return result + str[lastIndex:] 30 | } 31 | 32 | func Colorf(format string, a ...any) string { 33 | return fmt.Sprintf(Color(format), a...) 34 | } 35 | -------------------------------------------------------------------------------- /internal/utils/fs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | func ChownR(path string, uid, gid int) error { 9 | return filepath.Walk(path, func(name string, info os.FileInfo, err error) error { 10 | if err == nil { 11 | err = os.Chown(name, uid, gid) 12 | } 13 | return err 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /internal/utils/swal2.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | _ "embed" 5 | "html/template" 6 | "net/http" 7 | ) 8 | 9 | //go:embed swal2.html 10 | var swal2Template string 11 | 12 | func Swal2Response(w http.ResponseWriter, body string) { 13 | w.Header().Set("Content-Type", "text/html") 14 | 15 | tmpl, err := template.New("main").Parse(swal2Template) 16 | if err != nil { 17 | http.Error(w, err.Error(), 500) 18 | } 19 | 20 | err = tmpl.Execute(w, map[string]any{ 21 | "Body": template.HTML(body), 22 | }) 23 | 24 | if err != nil { 25 | http.Error(w, err.Error(), 500) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/utils/swal2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Neko rooms 8 | 9 | 268 | 269 | 270 | 271 | 274 | 275 |
276 | {{.Body}} 277 | Powered by: neko-rooms 278 |
279 | 280 | 281 | 282 | 283 | -------------------------------------------------------------------------------- /internal/utils/uid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math" 7 | ) 8 | 9 | const ( 10 | defaultAlphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" // len=62 11 | defaultSize = 21 12 | defaultMaskSize = 5 13 | ) 14 | 15 | // Generator function 16 | type Generator func([]byte) (int, error) 17 | 18 | // BytesGenerator is the default bytes generator 19 | var BytesGenerator Generator = rand.Read 20 | 21 | func initMasks(params ...int) []uint { 22 | var size int 23 | if len(params) == 0 { 24 | size = defaultMaskSize 25 | } else { 26 | size = params[0] 27 | } 28 | masks := make([]uint, size) 29 | for i := 0; i < size; i++ { 30 | shift := 3 + i 31 | masks[i] = (2 << uint(shift)) - 1 32 | } 33 | return masks 34 | } 35 | 36 | func getMask(alphabet string, masks []uint) int { 37 | for i := 0; i < len(masks); i++ { 38 | curr := int(masks[i]) 39 | if curr >= len(alphabet)-1 { 40 | return curr 41 | } 42 | } 43 | return 0 44 | } 45 | 46 | // GenerateUID is a low-level function to change alphabet and ID size. 47 | func GenerateUID(alphabet string, size int) (string, error) { 48 | if len(alphabet) == 0 || len(alphabet) > 255 { 49 | return "", fmt.Errorf("alphabet must not empty and contain no more than 255 chars. Current len is %d", len(alphabet)) 50 | } 51 | if size <= 0 { 52 | return "", fmt.Errorf("size must be positive integer") 53 | } 54 | 55 | masks := initMasks(size) 56 | mask := getMask(alphabet, masks) 57 | ceilArg := 1.6 * float64(mask*size) / float64(len(alphabet)) 58 | step := int(math.Ceil(ceilArg)) 59 | 60 | id := make([]byte, size) 61 | bytes := make([]byte, step) 62 | for j := 0; ; { 63 | _, err := BytesGenerator(bytes) 64 | if err != nil { 65 | return "", err 66 | } 67 | for i := 0; i < step; i++ { 68 | currByte := bytes[i] & byte(mask) 69 | if currByte < byte(len(alphabet)) { 70 | id[j] = alphabet[currByte] 71 | j++ 72 | if j == size { 73 | return string(id[:size]), nil 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | // NewUID generates secure URL-friendly unique ID. 81 | func NewUID(param ...int) (string, error) { 82 | var size int 83 | if len(param) == 0 { 84 | size = defaultSize 85 | } else { 86 | size = param[0] 87 | } 88 | bytes := make([]byte, size) 89 | _, err := BytesGenerator(bytes) 90 | if err != nil { 91 | return "", err 92 | } 93 | id := make([]byte, size) 94 | for i := 0; i < size; i++ { 95 | id[i] = defaultAlphabet[bytes[i]&61] 96 | } 97 | return string(id[:size]), nil 98 | } 99 | -------------------------------------------------------------------------------- /neko.go: -------------------------------------------------------------------------------- 1 | package neko_rooms 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "runtime" 8 | 9 | "github.com/docker/docker/client" 10 | "github.com/rs/zerolog" 11 | "github.com/rs/zerolog/log" 12 | "github.com/spf13/cobra" 13 | 14 | "github.com/m1k1o/neko-rooms/internal/api" 15 | "github.com/m1k1o/neko-rooms/internal/config" 16 | "github.com/m1k1o/neko-rooms/internal/proxy" 17 | "github.com/m1k1o/neko-rooms/internal/pull" 18 | "github.com/m1k1o/neko-rooms/internal/room" 19 | "github.com/m1k1o/neko-rooms/internal/server" 20 | ) 21 | 22 | const Header = `&34 23 | __ 24 | ____ ___ / /______ _________ ____ ____ ___ _____ 25 | / __ \/ _ \/ //_/ __ \ / ___/ __ \/ __ \/ __ '__ \/ ___/ 26 | / / / / __/ ,< / /_/ /_____/ / / /_/ / /_/ / / / / / (__ ) 27 | /_/ /_/\___/_/|_|\____/_____/_/ \____/\____/_/ /_/ /_/____/ 28 | 29 | &1&37 by m1k1o &33%s v%s&0 30 | ` 31 | 32 | var ( 33 | // 34 | buildDate = "dev" 35 | // 36 | gitCommit = "dev" 37 | // 38 | gitBranch = "dev" 39 | 40 | // Major version when you make incompatible API changes, 41 | major = "1" 42 | // Minor version when you add functionality in a backwards-compatible manner, and 43 | minor = "0" 44 | // Patch version when you make backwards-compatible bug fixes. 45 | patch = "0" 46 | ) 47 | 48 | var Service *MainCtx 49 | 50 | func init() { 51 | Service = &MainCtx{ 52 | Version: &Version{ 53 | Major: major, 54 | Minor: minor, 55 | Patch: patch, 56 | GitCommit: gitCommit, 57 | GitBranch: gitBranch, 58 | BuildDate: buildDate, 59 | GoVersion: runtime.Version(), 60 | Compiler: runtime.Compiler, 61 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 62 | }, 63 | Configs: &Configs{ 64 | Root: &config.Root{}, 65 | Server: &config.Server{}, 66 | Room: &config.Room{}, 67 | }, 68 | } 69 | } 70 | 71 | type Version struct { 72 | Major string 73 | Minor string 74 | Patch string 75 | GitCommit string 76 | GitBranch string 77 | BuildDate string 78 | GoVersion string 79 | Compiler string 80 | Platform string 81 | } 82 | 83 | func (i *Version) String() string { 84 | return fmt.Sprintf("%s.%s.%s %s", i.Major, i.Minor, i.Patch, i.GitCommit) 85 | } 86 | 87 | func (i *Version) Details() string { 88 | return fmt.Sprintf( 89 | "%s\n%s\n%s\n%s\n%s\n%s\n%s\n", 90 | fmt.Sprintf("Version %s.%s.%s", i.Major, i.Minor, i.Patch), 91 | fmt.Sprintf("GitCommit %s", i.GitCommit), 92 | fmt.Sprintf("GitBranch %s", i.GitBranch), 93 | fmt.Sprintf("BuildDate %s", i.BuildDate), 94 | fmt.Sprintf("GoVersion %s", i.GoVersion), 95 | fmt.Sprintf("Compiler %s", i.Compiler), 96 | fmt.Sprintf("Platform %s", i.Platform), 97 | ) 98 | } 99 | 100 | type Configs struct { 101 | Root *config.Root 102 | Server *config.Server 103 | Room *config.Room 104 | } 105 | 106 | type MainCtx struct { 107 | Version *Version 108 | Configs *Configs 109 | 110 | logger zerolog.Logger 111 | roomManager *room.RoomManagerCtx 112 | pullManager *pull.PullManagerCtx 113 | apiManager *api.ApiManagerCtx 114 | proxyManager *proxy.ProxyManagerCtx 115 | serverManager *server.ServerManagerCtx 116 | } 117 | 118 | func (main *MainCtx) Preflight() { 119 | main.logger = log.With().Str("service", "neko_rooms").Logger() 120 | } 121 | 122 | func (main *MainCtx) Start() { 123 | client, err := client.NewClientWithOpts(client.FromEnv) 124 | if err != nil { 125 | main.logger.Panic().Err(err).Msg("unable to connect to docker client") 126 | } else { 127 | main.logger.Info().Msg("successfully connected to docker client") 128 | } 129 | 130 | main.roomManager = room.New( 131 | client, 132 | main.Configs.Room, 133 | ) 134 | main.roomManager.EventsLoopStart() 135 | 136 | main.pullManager = pull.New( 137 | client, 138 | main.Configs.Room.NekoImages, 139 | ) 140 | 141 | main.apiManager = api.New( 142 | main.roomManager, 143 | main.pullManager, 144 | ) 145 | 146 | main.proxyManager = proxy.New( 147 | main.roomManager, 148 | main.Configs.Room.WaitEnabled, 149 | ) 150 | main.proxyManager.Start() 151 | 152 | main.serverManager = server.New( 153 | main.apiManager, 154 | main.Configs.Room, 155 | main.Configs.Server, 156 | main.proxyManager, 157 | ) 158 | main.serverManager.Start() 159 | } 160 | 161 | func (main *MainCtx) Shutdown() { 162 | var err error 163 | 164 | err = main.serverManager.Shutdown() 165 | main.logger.Err(err).Msg("server manager shutdown") 166 | 167 | err = main.proxyManager.Shutdown() 168 | main.logger.Err(err).Msg("proxy manager shutdown") 169 | 170 | err = main.pullManager.Shutdown() 171 | main.logger.Err(err).Msg("pull manager shutdown") 172 | 173 | err = main.roomManager.EventsLoopStop() 174 | main.logger.Err(err).Msg("room events loop shutdown") 175 | } 176 | 177 | func (main *MainCtx) ServeCommand(cmd *cobra.Command, args []string) { 178 | main.logger.Info().Msg("starting neko_rooms server") 179 | main.Start() 180 | main.logger.Info().Msg("neko_rooms ready") 181 | 182 | quit := make(chan os.Signal, 1) 183 | signal.Notify(quit, os.Interrupt) 184 | sig := <-quit 185 | 186 | main.logger.Warn().Msgf("received %s, attempting graceful shutdown.", sig) 187 | main.Shutdown() 188 | main.logger.Info().Msg("shutdown complete") 189 | } 190 | -------------------------------------------------------------------------------- /pkg/prefix/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package prefix implements a tree data structure that can be used to store 3 | and retrieve values based on a path prefix. It can be used to match paths 4 | against a set of prefixes. 5 | */ 6 | package prefix 7 | -------------------------------------------------------------------------------- /pkg/prefix/tree.go: -------------------------------------------------------------------------------- 1 | package prefix 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type Tree[T any] interface { 8 | Insert(prefix string, value T) 9 | Find(prefix string) (value T, ok bool) 10 | Remove(prefix string) 11 | Match(path string) (value T, prefix string, ok bool) 12 | } 13 | 14 | type tree[T any] struct { 15 | Value T 16 | IsLeaf bool 17 | Children map[string]*tree[T] 18 | } 19 | 20 | func NewTree[T any]() *tree[T] { 21 | return &tree[T]{} 22 | } 23 | 24 | func (p *tree[T]) Insert(prefix string, value T) { 25 | arr := strings.Split(prefix, "/") 26 | l := len(arr) 27 | 28 | for i, a := range arr { 29 | if a == "" { 30 | continue 31 | } 32 | 33 | if p.Children == nil { 34 | p.Children = map[string]*tree[T]{} 35 | } 36 | 37 | dat, ok := p.Children[a] 38 | if !ok { 39 | dat = &tree[T]{} 40 | } 41 | 42 | if i == l-1 { 43 | dat.Value = value 44 | dat.IsLeaf = true 45 | dat.Children = nil 46 | } 47 | 48 | p.Children[a] = dat 49 | p = dat 50 | } 51 | } 52 | 53 | func (p *tree[T]) Find(prefix string) (value T, ok bool) { 54 | arr := strings.Split(prefix, "/") 55 | l := len(arr) 56 | 57 | for i, a := range arr { 58 | if a == "" { 59 | continue 60 | } 61 | 62 | if p.Children == nil { 63 | p.Children = map[string]*tree[T]{} 64 | } 65 | 66 | dat, found := p.Children[a] 67 | if !found { 68 | dat = &tree[T]{} 69 | } 70 | 71 | if i == l-1 { 72 | value = dat.Value 73 | ok = dat.IsLeaf 74 | return 75 | } 76 | 77 | p.Children[a] = dat 78 | p = dat 79 | } 80 | 81 | return 82 | } 83 | 84 | func (p *tree[T]) Remove(prefix string) { 85 | arr := strings.Split(prefix, "/") 86 | l := len(arr) 87 | 88 | ptrs := []*tree[T]{p} 89 | for i, a := range arr { 90 | if a == "" { 91 | continue 92 | } 93 | 94 | if i == l-1 { 95 | delete(p.Children, a) 96 | } 97 | 98 | dat, found := p.Children[a] 99 | if !found { 100 | break 101 | } 102 | p = dat 103 | ptrs = append(ptrs, p) 104 | } 105 | 106 | // remove all empty references 107 | rm := false 108 | for i := len(ptrs) - 1; i >= 0; i-- { 109 | if len(ptrs[i].Children) == 0 { 110 | rm = true 111 | continue 112 | } 113 | if rm { 114 | ptrs[i].Children = nil 115 | } 116 | } 117 | } 118 | 119 | func (p *tree[T]) Match(path string) (value T, prefix string, ok bool) { 120 | arr := strings.Split(path, "/") 121 | prefixArr := []string{} 122 | 123 | for _, a := range arr { 124 | if a == "" { 125 | continue 126 | } 127 | 128 | pointer, found := p.Children[a] 129 | if !found { 130 | return 131 | } 132 | 133 | p = pointer 134 | prefixArr = append(prefixArr, a) 135 | 136 | if p.IsLeaf { 137 | break 138 | } 139 | } 140 | 141 | value = p.Value 142 | prefix = "/" + strings.Join(prefixArr, "/") 143 | ok = p.IsLeaf 144 | return 145 | } 146 | -------------------------------------------------------------------------------- /pkg/prefix/tree_test.go: -------------------------------------------------------------------------------- 1 | package prefix 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestMatch(t *testing.T) { 9 | // create a new prefix tree 10 | tree := NewTree[string]() 11 | 12 | // add some values to the tree 13 | tree.Insert("/users/1", "user1") 14 | tree.Insert("/users/2", "user2") 15 | 16 | // test matching a path that exists in the tree 17 | value, prefix, ok := tree.Match("/users/1") 18 | if !ok || value != "user1" || prefix != "/users/1" { 19 | t.Errorf("Match failed for /users/1") 20 | } 21 | 22 | // test matching a path that exists in the tree but has a trailing slash 23 | value, prefix, ok = tree.Match("/users/2/") 24 | if !ok || value != "user2" || prefix != "/users/2" { 25 | t.Errorf("Match failed for /users/2/") 26 | } 27 | 28 | // test matching a path that does not exist in the tree 29 | _, _, ok = tree.Match("/users/4") 30 | if ok { 31 | t.Errorf("Match should have failed for /users/4") 32 | } 33 | 34 | // test matching a path that partially exists in the tree 35 | value, prefix, ok = tree.Match("/users/1/posts") 36 | if !ok || value != "user1" || prefix != "/users/1" { 37 | t.Errorf("Match failed for /users/1/posts: %v %v %v", value, prefix, ok) 38 | } 39 | } 40 | 41 | func TestFind(t *testing.T) { 42 | // create a new prefix tree 43 | tree := NewTree[string]() 44 | 45 | // add some values to the tree 46 | tree.Insert("/users/1", "user1") 47 | tree.Insert("/users/2", "user2") 48 | 49 | // test finding a path that exists in the tree 50 | value, ok := tree.Find("/users/1") 51 | if !ok || value != "user1" { 52 | t.Errorf("Find failed for /users/1") 53 | } 54 | 55 | // test finding a path that exists in the tree but has a trailing slash 56 | _, ok = tree.Find("/users/2/") 57 | if ok { 58 | t.Errorf("Find should have failed for /users/2/") 59 | } 60 | 61 | // test finding a path that does not exist in the tree 62 | _, ok = tree.Find("/users/4") 63 | if ok { 64 | t.Errorf("Find should have failed for /users/4") 65 | } 66 | 67 | // test finding a path that partially exists in the tree 68 | _, ok = tree.Find("/users/1/posts") 69 | if ok { 70 | t.Errorf("Find should have failed for /users/1/posts") 71 | } 72 | } 73 | 74 | func TestRemove(t *testing.T) { 75 | // create a new prefix tree 76 | tree := &tree[string]{} 77 | 78 | // add some values to the tree 79 | tree.Insert("/users/1", "user1") 80 | tree.Insert("/users/2", "user2") 81 | 82 | // test matching a path that exists in the tree 83 | value, prefix, ok := tree.Match("/users/1") 84 | if !ok || value != "user1" || prefix != "/users/1" { 85 | t.Errorf("Match failed for /users/1") 86 | } 87 | 88 | // remove a path that exists in the tree 89 | tree.Remove("/users/1") 90 | 91 | // test matching a path that exists in the tree 92 | _, _, ok = tree.Match("/users/1") 93 | if ok { 94 | t.Errorf("Match should have failed for /users/1") 95 | } 96 | 97 | // test finding a path that exists in the tree 98 | _, ok = tree.Find("/users/1") 99 | if ok { 100 | t.Errorf("Find should have failed for /users/1") 101 | } 102 | 103 | // remove another path that exists in the tree 104 | tree.Remove("/users/2") 105 | 106 | // test matching a path that exists in the tree 107 | _, _, ok = tree.Match("/users/2") 108 | if ok { 109 | t.Errorf("Match should have failed for /users/2") 110 | } 111 | 112 | // check that the tree is empty 113 | if tree.Children != nil { 114 | t.Errorf("Tree should be empty, but contains %d children", len(tree.Children)) 115 | for k, v := range tree.Children { 116 | t.Errorf("%s: %v", k, v) 117 | } 118 | } 119 | } 120 | 121 | // Benchmark the match function 122 | func Benchmark(b *testing.B) { 123 | // create a new prefix tree 124 | tree := NewTree[string]() 125 | 126 | // insert 1 - 10 levels 127 | level := "/users" 128 | for i := 1; i <= 10; i++ { 129 | level += fmt.Sprintf("/%d", i) 130 | tree.Insert(level, fmt.Sprintf("level_%d", i)) 131 | } 132 | 133 | // run the benchmark 134 | level = "/users" 135 | for i := 0; i <= 10; i++ { 136 | level += fmt.Sprintf("/%d", i) 137 | b.Run(fmt.Sprintf("level_%d", i), func(b *testing.B) { 138 | for i := 0; i < b.N; i++ { 139 | tree.Match(level) 140 | } 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /traefik/.env.example: -------------------------------------------------------------------------------- 1 | NEKO_ROOMS_EPR=59000-59049 2 | NEKO_ROOMS_NAT1TO1=192.168.1.20 3 | 4 | NEKO_ROOMS_TRAEFIK_DOMAIN=neko-rooms.server.lan 5 | NEKO_ROOMS_TRAEFIK_ENTRYPOINT=websecure 6 | NEKO_ROOMS_TRAEFIK_NETWORK=neko-rooms-traefik 7 | NEKO_ROOMS_TRAEFIK_CERTRESOLVER=lets-encrypt 8 | 9 | TZ=Europe/Vienna 10 | -------------------------------------------------------------------------------- /traefik/.gitignore: -------------------------------------------------------------------------------- 1 | acme.json 2 | usersfile 3 | -------------------------------------------------------------------------------- /traefik/README.md: -------------------------------------------------------------------------------- 1 | # Installation guide for traefik as reverse proxy 2 | 3 | ## Zero-knowledge installation 4 | 5 | If you don't have any clue about docker and stuff but only want to have fun with friends in a shared browser, we got you covered! 6 | 7 | - Rent a VPS with public IP and OS Ubuntu. 8 | - Get a domain name pointing to your IP (you can even get some for free). 9 | - Run install script and follow instructions. 10 | 11 | ```bash 12 | wget -O neko-rooms.sh https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/install 13 | sudo bash neko-rooms.sh 14 | ``` 15 | 16 | ## How to start 17 | 18 | You need to have installed `Docker` and `docker-compose`. You need to have a custom domain pointing to your server's IP. 19 | 20 | You can watch installation video provided by *Dr R1ck*: 21 | 22 | https://www.youtube.com/watch?v=cCmnw-pq0gA 23 | 24 | ### Installation guide 25 | 26 | You only need `.env.example`, `docker-compose.yml` and `traefik/`. 27 | 28 | #### Do I need to use traefik? 29 | 30 | - This project started with Traefik as a needed dependency. That, however, changed. Traefik must not be used but the original setup can still be used. 31 | - Traefik is used to forward traffic to the rooms. You can put nginx in front of it. 32 | - See example configuration for [nginx](./nginx). 33 | 34 | You can use `docker-compose.http.yml` that will expose this service to `8080` or any port. Authentication is optional. Start it quickly with `docker-compose -f docker-compose.http.yml up -d`. 35 | 36 | ### Step 1 37 | 38 | Copy `.env.example` to `.env` and customize. 39 | 40 | ```bash 41 | cp .env.example .env 42 | ``` 43 | 44 | ### Step 2 45 | 46 | Create `usersfile` with your users: 47 | 48 | ```bash 49 | touch usersfile 50 | ``` 51 | 52 | And add as many users as you like: 53 | 54 | ```bash 55 | echo $(htpasswd -nb user password) >> usersfile 56 | ``` 57 | 58 | ### Step 3 (HTTPS only) 59 | 60 | Create `acme.json` 61 | 62 | ```bash 63 | touch acme.json 64 | chmod 600 acme.json 65 | ``` 66 | 67 | Update your email in `traefik.yml`. 68 | -------------------------------------------------------------------------------- /traefik/config/middlewares.yml: -------------------------------------------------------------------------------- 1 | http: 2 | middlewares: 3 | basicauth: 4 | basicAuth: 5 | usersFile: "/usersfile" 6 | 7 | httpsredirect: 8 | redirectScheme: 9 | scheme: https 10 | permanent: true 11 | -------------------------------------------------------------------------------- /traefik/config/routers.yml: -------------------------------------------------------------------------------- 1 | http: 2 | routers: 3 | redirecttohttps: 4 | entryPoints: 5 | - "web" 6 | middlewares: 7 | - "httpsredirect" 8 | rule: "HostRegexp(`{host:.+}`)" 9 | service: "noop@internal" 10 | -------------------------------------------------------------------------------- /traefik/config/tls.yml: -------------------------------------------------------------------------------- 1 | tls: 2 | options: 3 | default: 4 | minVersion: VersionTLS12 5 | cipherSuites: 6 | - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 7 | - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 8 | - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 9 | - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 10 | - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 11 | - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 12 | -------------------------------------------------------------------------------- /traefik/docker-compose.http.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | networks: 4 | default: 5 | attachable: true 6 | name: "neko-rooms-traefik" 7 | 8 | services: 9 | traefik: 10 | image: "traefik:2.4" 11 | restart: "unless-stopped" 12 | environment: 13 | - "TZ=Europe/Vienna" 14 | command: 15 | - "--providers.docker=true" 16 | - "--providers.docker.watch=true" 17 | - "--providers.docker.exposedbydefault=false" 18 | - "--providers.docker.network=neko-rooms-traefik" 19 | - "--entrypoints.web.address=:8080" 20 | ports: 21 | - target: 8080 22 | published: 8080 23 | protocol: "tcp" 24 | mode: "host" 25 | volumes: 26 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 27 | # OPTIONAL: Enable authentication. 28 | # - "./traefik/usersfile:/usersfile:ro" 29 | 30 | neko-rooms: 31 | image: "m1k1o/neko-rooms:latest" 32 | restart: "unless-stopped" 33 | environment: 34 | - "TZ=Europe/Vienna" 35 | - "NEKO_ROOMS_EPR=59000-59049" 36 | - "NEKO_ROOMS_NAT1TO1=10.8.0.1" # IP address of your server 37 | - "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=web" 38 | - "NEKO_ROOMS_TRAEFIK_NETWORK=neko-rooms-traefik" 39 | - "NEKO_ROOMS_INSTANCE_URL=http://10.8.0.1:8080/" # external URL 40 | volumes: 41 | - "/var/run/docker.sock:/var/run/docker.sock" 42 | labels: 43 | - "traefik.enable=true" 44 | - "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080" 45 | - "traefik.http.routers.neko-rooms.entrypoints=web" 46 | - "traefik.http.routers.neko-rooms.rule=HostRegexp(`{host:.+}`)" 47 | - "traefik.http.routers.neko-rooms.priority=1" 48 | # OPTIONAL: Enable authentication. 49 | # - "traefik.http.middlewares.nrooms-auth.basicauth.usersfile=/usersfile" 50 | # - "traefik.http.routers.neko-rooms.middlewares=nrooms-auth" 51 | -------------------------------------------------------------------------------- /traefik/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | # 4 | # This docker compose needs .env file. 5 | # Copy .env.example to .env and modify. 6 | # 7 | 8 | networks: 9 | default: 10 | attachable: true 11 | name: "${NEKO_ROOMS_TRAEFIK_NETWORK}" 12 | 13 | services: 14 | traefik: 15 | image: "traefik:2.4" 16 | restart: "unless-stopped" 17 | environment: 18 | - "TZ" 19 | ports: 20 | - target: 80 21 | published: 80 22 | protocol: "tcp" 23 | mode: "host" 24 | - target: 443 25 | published: 443 26 | protocol: "tcp" 27 | mode: "host" 28 | volumes: 29 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 30 | - "./traefik.yml:/etc/traefik/traefik.yml:ro" 31 | - "./usersfile:/usersfile:ro" 32 | - "./acme.json:/acme.json" 33 | - "./config:/config" 34 | 35 | neko-rooms: 36 | image: "m1k1o/neko-rooms:latest" 37 | restart: "unless-stopped" 38 | env_file: 39 | - ".env" 40 | volumes: 41 | - "/var/run/docker.sock:/var/run/docker.sock" 42 | labels: 43 | - "traefik.enable=true" 44 | - "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080" 45 | - "traefik.http.routers.neko-rooms.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" 46 | - "traefik.http.routers.neko-rooms.rule=Host(`${NEKO_ROOMS_TRAEFIK_DOMAIN}`)" 47 | - "traefik.http.routers.neko-rooms.tls=true" 48 | - "traefik.http.routers.neko-rooms.tls.certresolver=${NEKO_ROOMS_TRAEFIK_CERTRESOLVER}" 49 | - "traefik.http.routers.neko-rooms.middlewares=basicauth@file" 50 | -------------------------------------------------------------------------------- /traefik/install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo " 4 | --------------------------------------------------------------- 5 | __ 6 | ____ ___ / /______ _________ ____ ____ ___ _____ 7 | / __ \/ _ \/ //_/ __ \ / ___/ __ \/ __ \/ __ '__ \/ ___/ 8 | / / / / __/ ,< / /_/ /_____/ / / /_/ / /_/ / / / / / (__ ) 9 | /_/ /_/\___/_/|_|\____/_____/_/ \____/\____/_/ /_/ /_/____/ 10 | 11 | Automatic installer by m1k1o 12 | --------------------------------------------------------------- 13 | 14 | You need to have: 15 | 16 | - OS: 17 | - Kernel version 2 or higher. 18 | - Debian 9 or higher. 19 | - Ubuntu 18.04 or higher. 20 | 21 | - Hardware: 22 | - Memory at least 2GB. 23 | - CPU at least 4 cores. 24 | - Disk at least 8GB. 25 | 26 | - Network: 27 | - Public IP. 28 | - Free TCP ports 80 and 443. 29 | - Free UDP port range (59000-59100). 30 | - Domain name pointing to your IP. 31 | 32 | - Run this script as superuser. 33 | " 34 | 35 | while true; do 36 | read -rp "Are you ready to continue? [Y/n] " yn 37 | case $yn in 38 | "") break ;; 39 | [Yy]*) break ;; 40 | [Nn]*) exit 0 ;; 41 | *) echo "Please answer yes or no." ;; 42 | esac 43 | done 44 | 45 | # Detect Debian users running the script with "sh" instead of bash 46 | if readlink /proc/"$$"/exe | grep -q "dash"; then 47 | echo 'This installer needs to be run with "bash", not "sh".' >&2 48 | exit 1 49 | fi 50 | 51 | # Detect Root 52 | if [[ "${EUID}" -ne 0 ]]; then 53 | echo "This installer needs to be run with superuser privileges." >&2 54 | exit 1 55 | fi 56 | 57 | # Detect OS 58 | if grep -qs "ubuntu" /etc/os-release; then 59 | OS_VERSION="$(grep 'VERSION_ID' /etc/os-release | cut -d '"' -f 2 | tr -d '.')" 60 | 61 | if [[ "${OS_VERSION}" -lt 1804 ]]; then 62 | echo "Ubuntu 18.04 or higher is required to use this installer." >&2 63 | echo "This version of Ubuntu is too old and unsupported." >&2 64 | exit 1 65 | fi 66 | elif [[ -e /etc/debian_version ]]; then 67 | OS_VERSION="$(grep -oE '[0-9]+' /etc/debian_version | head -1)" 68 | 69 | if [[ "${OS_VERSION}" -lt 9 ]]; then 70 | echo "Debian 9 or higher is required to use this installer." >&2 71 | echo "This version of Debian is too old and unsupported." >&2 72 | exit 1 73 | fi 74 | else 75 | echo "This installer seems to be running on an unsupported distribution." >&2 76 | echo "Supported distributions are Ubuntu and Debian." >&2 77 | exit 1 78 | fi 79 | 80 | # Detect Kernel 81 | if [[ "$(uname -r | cut -d "." -f 1)" -eq 2 ]]; then 82 | echo "The system is running an old kernel, which is incompatible with this installer." >&2 83 | exit 1 84 | fi 85 | 86 | # 87 | # Install docker 88 | # 89 | 90 | if ! dockerd --help > /dev/null 2>&1; then 91 | while true; do 92 | read -rp "Docker is not installed. Do you wish to install this program? [Y/n]" yn 93 | case $yn in 94 | [Yy]*) break ;; 95 | [Nn]*) exit 0 ;; 96 | *) echo "Please answer yes or no." ;; 97 | esac 98 | done 99 | 100 | apt-get remove containerd docker docker-engine docker.io runc 101 | apt-get update 102 | apt-get install -y \ 103 | apt-transport-https \ 104 | ca-certificates \ 105 | curl \ 106 | gnupg \ 107 | lsb-release 108 | 109 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg 110 | 111 | echo \ 112 | "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \ 113 | $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null 114 | 115 | apt-get update 116 | apt-get install -y containerd.io docker-ce docker-ce-cli docker-buildx-plugin docker-compose-plugin 117 | fi 118 | 119 | echo "[Y] Docker is installed..." 120 | 121 | # 122 | # Install dependencies 123 | # 124 | 125 | apt-get update 126 | apt-get install -y apache2-utils sed 127 | 128 | echo "[Y] Dependencies are installed..." 129 | 130 | # 131 | # Prompt for data 132 | # 133 | 134 | # Epr 135 | read -rp "Enter UDP port range: (default 59000-59100) " NEKO_ROOMS_EPR 136 | if [[ -z "${NEKO_ROOMS_EPR}" ]]; then 137 | NEKO_ROOMS_EPR="59000-59100" 138 | fi 139 | 140 | # Domain 141 | while true; do 142 | read -rp "Enter your domain name: (e.g. example.com) " NEKO_ROOMS_TRAEFIK_DOMAIN 143 | if [[ -z "${NEKO_ROOMS_TRAEFIK_DOMAIN}" ]]; then 144 | echo "Please enter your domain." 145 | continue 146 | fi 147 | 148 | break 149 | done 150 | 151 | # Timezone 152 | TZ_DEF="$(cat /etc/timezone)" 153 | read -rp "Current timezone: (default ${TZ_DEF}) " TZ 154 | if [[ -z "${TZ}" ]]; then 155 | TZ="${TZ_DEF}" 156 | fi 157 | 158 | # Email 159 | while true; do 160 | read -rp "Enter your email for Let's Encrypt domain notification: " TRAEFIK_EMAIL 161 | if [[ -z "${TRAEFIK_EMAIL}" ]]; then 162 | echo "Please enter your email. Or, well, use fake if you want..." 163 | continue 164 | fi 165 | 166 | break 167 | done 168 | 169 | touch "./usersfile" 170 | 171 | # Users 172 | while true; do 173 | echo "Add new user:" 174 | 175 | # Username 176 | read -rp " | - Username: (default admin) " USR_NAME 177 | if [[ -z "${USR_NAME}" ]]; then 178 | USR_NAME="admin" 179 | fi 180 | 181 | # Password 182 | read -rp " | - Password: (default admin) " -s USR_PASS 183 | if [[ -z "${USR_PASS}" ]]; then 184 | USR_PASS="admin" 185 | fi 186 | 187 | htpasswd -nb "${USR_NAME}" "${USR_PASS}" >> usersfile 188 | 189 | echo 190 | read -rp "Do you want to add another user? [y/N] " yn 191 | case $yn in 192 | "") break ;; 193 | [Yy]*) echo ;; 194 | [Nn]*) break ;; 195 | *) echo "Please answer yes or no." ;; 196 | esac 197 | done 198 | 199 | echo "[Y] Got all settings..." 200 | 201 | # 202 | # Create env 203 | # 204 | 205 | { 206 | echo "TZ=${TZ}" 207 | echo "NEKO_ROOMS_EPR=${NEKO_ROOMS_EPR}" 208 | echo "NEKO_ROOMS_TRAEFIK_DOMAIN=${NEKO_ROOMS_TRAEFIK_DOMAIN}" 209 | echo "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=websecure" 210 | echo "NEKO_ROOMS_TRAEFIK_NETWORK=neko-rooms-traefik" 211 | echo "NEKO_ROOMS_TRAEFIK_CERTRESOLVER=lets-encrypt" 212 | } > .env 213 | 214 | echo "[Y] Creating env..." 215 | 216 | # 217 | # Download traefik config 218 | # 219 | 220 | mkdir -p "./config" 221 | 222 | wget -O "./traefik.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/traefik.yml" 223 | sed -i "s/yourname@example.com/${TRAEFIK_EMAIL}/g" "./traefik.yml" 224 | 225 | wget -O "./config/middlewares.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/middlewares.yml" 226 | wget -O "./config/routers.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/routers.yml" 227 | wget -O "./config/tls.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/tls.yml" 228 | 229 | touch "./acme.json" 230 | chmod 600 "./acme.json" 231 | 232 | echo "[Y] Downloading traefik config..." 233 | 234 | # 235 | # Download docker compose file 236 | # 237 | 238 | wget -O "./docker-compose.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/docker-compose.yml" 239 | 240 | # docker-compose was renamed to docker compose, support both 241 | if docker-compose --version > /dev/null 2>&1; then 242 | docker-compose pull 243 | docker-compose up -d 244 | else 245 | docker compose pull 246 | docker compose up -d 247 | fi 248 | 249 | echo "[Y] Finished! You can now visit https://${NEKO_ROOMS_TRAEFIK_DOMAIN}/" 250 | -------------------------------------------------------------------------------- /traefik/nginx-auth/README.md: -------------------------------------------------------------------------------- 1 | # Installation guide for NGINX as a reverse proxy with authentication 2 | 3 | First, make sure that you have your `.htpasswd` file created, if you don't, you can follow this guide in the NGINX docs: 4 | 5 | https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/ 6 | 7 | This guide assumes that your `.htpasswd` file is located at `/etc/apache2/.htpasswd`. 8 | 9 | ## Installation: 10 | 11 | Download your `docker-compose.yml` file, and make necessary changes to the environment variables, more notably, `NEKO_ROOMS_NAT1TO1` and `NEKO_ROOMS_INSTANCE_URL`. 12 | 13 | Run your docker-compose file, done by running `sudo docker-compose up -d` on Linux in the same directory as your compose file. 14 | 15 | Next, move onto NGINX. First, open up your NGINX config, and make any alterations necessary for the traefik `proxy-pass`; 16 | The container's IP will also work there. 17 | Your NGINX config at this point should be good to go, install and restart NGINX! 18 | 19 | ## Certificates: 20 | 21 | If you wish to have SSL for Neko, you can use certbot to get that done! A guide is linked below for installation. 22 | 23 | https://certbot.eff.org/instructions 24 | -------------------------------------------------------------------------------- /traefik/nginx-auth/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | networks: 4 | default: 5 | attachable: true 6 | name: "neko-rooms-traefik" 7 | 8 | services: 9 | traefik: # domain name used in nginx config 10 | image: "traefik:2.4" 11 | restart: "unless-stopped" 12 | environment: 13 | - "TZ=Europe/Vienna" 14 | command: 15 | - "--providers.docker=true" 16 | - "--providers.docker.watch=true" 17 | - "--providers.docker.exposedbydefault=false" 18 | - "--providers.docker.network=neko-rooms-traefik" 19 | - "--entrypoints.web.address=:8080" 20 | volumes: 21 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 22 | 23 | neko-rooms: 24 | image: "m1k1o/neko-rooms:latest" 25 | restart: "unless-stopped" 26 | environment: 27 | - "TZ=Europe/Vienna" 28 | - "NEKO_ROOMS_EPR=59000-59049" 29 | - "NEKO_ROOMS_NAT1TO1=10.8.0.1" # IP address of your server 30 | - "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=web" 31 | - "NEKO_ROOMS_TRAEFIK_NETWORK=neko-rooms-traefik" 32 | - "NEKO_ROOMS_INSTANCE_URL=http://10.8.0.1:8080/" # external URL 33 | - "NEKO_ROOMS_STORAGE_ENABLED=true" 34 | - "NEKO_ROOMS_STORAGE_INTERNAL=/data" 35 | - "NEKO_ROOMS_STORAGE_EXTERNAL=/opt/neko-rooms/data" 36 | - "NEKO_ROOMS_PATH_PREFIX=/rooms/" 37 | volumes: 38 | - "/var/run/docker.sock:/var/run/docker.sock" 39 | - "/opt/neko-rooms/data:/data" 40 | labels: 41 | - "traefik.enable=true" 42 | - "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080" 43 | - "traefik.http.routers.neko-rooms.entrypoints=web" 44 | - "traefik.http.routers.neko-rooms.rule=HostRegexp(`{host:.+}`)" 45 | - "traefik.http.routers.neko-rooms.priority=1" 46 | -------------------------------------------------------------------------------- /traefik/nginx-auth/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name [DOMAIN]; 3 | listen 80; 4 | 5 | location / { 6 | proxy_pass http://traefik:8080; # traefik domain (name of the service) 7 | proxy_http_version 1.1; 8 | proxy_set_header Upgrade $http_upgrade; 9 | proxy_set_header Connection "upgrade"; 10 | proxy_read_timeout 86400; 11 | auth_basic "Authentication Required"; 12 | auth_basic_user_file /etc/apache2/.htpasswd; 13 | } 14 | 15 | location /rooms { 16 | proxy_pass http://traefik:8080; # traefik domain (name of the service) 17 | proxy_http_version 1.1; 18 | proxy_set_header Upgrade $http_upgrade; 19 | proxy_set_header Connection "upgrade"; 20 | proxy_read_timeout 86400; 21 | auth_basic off; 22 | } 23 | } -------------------------------------------------------------------------------- /traefik/nginx/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | networks: 4 | default: 5 | attachable: true 6 | name: "neko-rooms-traefik" 7 | 8 | services: 9 | traefik: # domain name used in nginx config 10 | image: "traefik:2.4" 11 | restart: "unless-stopped" 12 | environment: 13 | - "TZ=Europe/Vienna" 14 | command: 15 | - "--providers.docker=true" 16 | - "--providers.docker.watch=true" 17 | - "--providers.docker.exposedbydefault=false" 18 | - "--providers.docker.network=neko-rooms-traefik" 19 | - "--entrypoints.web.address=:8080" 20 | volumes: 21 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 22 | 23 | neko-rooms: 24 | image: "m1k1o/neko-rooms:latest" 25 | restart: "unless-stopped" 26 | environment: 27 | - "TZ=Europe/Vienna" 28 | - "NEKO_ROOMS_EPR=59000-59049" 29 | - "NEKO_ROOMS_NAT1TO1=10.8.0.1" # IP address of your server 30 | - "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=web" 31 | - "NEKO_ROOMS_TRAEFIK_NETWORK=neko-rooms-traefik" 32 | - "NEKO_ROOMS_INSTANCE_URL=http://10.8.0.1:8080/" # external URL 33 | volumes: 34 | - "/var/run/docker.sock:/var/run/docker.sock" 35 | labels: 36 | - "traefik.enable=true" 37 | - "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080" 38 | - "traefik.http.routers.neko-rooms.entrypoints=web" 39 | - "traefik.http.routers.neko-rooms.rule=HostRegexp(`{host:.+}`)" 40 | - "traefik.http.routers.neko-rooms.priority=1" 41 | 42 | # 43 | # This should be replaced by your own existing nginx instance 44 | # 45 | 46 | nginx: 47 | image: "nginx" 48 | restart: "unless-stopped" 49 | environment: 50 | - "TZ=Europe/Vienna" 51 | ports: 52 | - "8080:80" 53 | volumes: 54 | - "./nginx.conf:/etc/nginx/conf.d/default.conf:ro" 55 | -------------------------------------------------------------------------------- /traefik/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | proxy_pass http://traefik:8080; 6 | proxy_http_version 1.1; 7 | proxy_set_header Upgrade $http_upgrade; 8 | proxy_set_header Connection "upgrade"; 9 | proxy_read_timeout 86400; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /traefik/traefik.yml: -------------------------------------------------------------------------------- 1 | entryPoints: 2 | web: 3 | address: ":80" 4 | websecure: 5 | address: ":443" 6 | forwardedHeaders: 7 | trustedIPs: 8 | - "10.0.0.0/8" 9 | - "172.16.0.0/12" 10 | - "192.168.0.0/16" 11 | 12 | providers: 13 | file: 14 | directory: "/config" 15 | docker: 16 | endpoint: "unix:///var/run/docker.sock" 17 | network: traefik 18 | watch: true 19 | exposedByDefault: false 20 | 21 | certificatesResolvers: 22 | lets-encrypt: 23 | acme: 24 | email: yourname@example.com 25 | storage: /acme.json 26 | httpChallenge: 27 | entryPoint: web 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./client/tsconfig.json" 3 | } --------------------------------------------------------------------------------