78 Commits

Author SHA1 Message Date
Miroslav Šedivý c45f17fa1f fix api. 2022-10-30 11:33:38 +01:00
brian 1205dc9587 Merge branch 'master' of https://github.com/m1k1o/neko-rooms into room_snapshots 2022-10-15 04:23:37 +00:00
brian fa401571e0 Add snapshot command 2022-10-15 04:09:44 +00:00
Miroslav Šedivý 3e77a1848b add wait enabled. 2022-09-18 12:32:15 +02:00
Miroslav Šedivý 7b71067da0 add room wait handler. 2022-09-18 12:32:15 +02:00
Miroslav Šedivý 41facda0bc update chromium policies. 2022-08-20 23:26:29 +02:00
Miroslav Šedivý 3850f3ddfa update extensions. 2022-08-20 23:23:36 +02:00
Miroslav Šedivý d873ae9126 fix install script. 2022-08-13 10:40:17 +02:00
Miroslav Šedivý 65d4a2b1a2 hide stats if room is not running. 2022-08-05 18:08:07 +02:00
Miroslav Šedivý 204300ff8a add extensions. 2022-08-04 22:42:23 +02:00
Miroslav Šedivý bff4933778 add Google Input Tools extension. https://github.com/m1k1o/neko/issues/191 2022-08-04 22:25:55 +02:00
Miroslav Šedivý 8146dd14cb allow extensions to be only for one browser. 2022-08-04 22:25:30 +02:00
Miroslav Šedivý e23f4ff89f client cleanup. 2022-08-04 22:20:00 +02:00
Miroslav Šedivý 9a05341188 stats error reporting. 2022-08-04 22:14:42 +02:00
Miroslav Šedivý 83d3c52e0e typo. 2022-08-04 22:00:40 +02:00
Miroslav Šedivý cc58d8fe52 add copy to clipboard button #66. 2022-08-04 21:59:24 +02:00
Miroslav Šedivý aab6311b83 warning if unhealthy. 2022-08-04 21:08:17 +02:00
Miroslav Šedivý 3ef6387153 fix req headers. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 3e23fe5d0d fix x-forwarded headers 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 9bc34e38c9 proxy auth add x-forwarded headers. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 3b7f8091f8 add proxy auth. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 33dcb2e7ea if not proxying, check for container readiness status. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 71c3628b4a fix traefik dev. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 8fe924387d remove admin path prefix. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý e68550a9d4 revert changes. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 8f6e6d5241 remove api config, 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 427d350e3d join static, api and proxy in server. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 17b54f8a8f prefix handler is leaf. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 090a54e2c1 client server for all paths. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 554cf3c5e6 clean path prefixes. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý ae2582deaf replace lobby with proxy. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 917dea5cbb upgrade to go 1.18. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý b744d491db proxy fix logging. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 29727e307e proxy store context in labels. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 6f35d8f2a3 max connections 0 when using mux. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 15b30babce admin path prefix & password. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 61eda24521 default path prefix /. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý aa9880e999 cleanup traefik readme. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý fe13a63104 move docker compose to traefik. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý f155b0868c split traefik in dev. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 0a0cac97ca set traefik config only if enabled. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 3c7bec1383 traefik.network -> instance.network. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý edc53b879a traefik enabled. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 8046607742 flexible url if no traefik. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý bb97bb75d2 traefik enable in config. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 91805fcaab internal proxy & admin path prefix. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 247f351206 move lobby to proxy. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 552089ec85 fix proxy for not running rooms. 2022-08-04 00:44:17 +02:00
Miroslav Šedivý 679142a471 add proxy (WIP). 2022-08-04 00:44:17 +02:00
Miroslav Šedivý b5c0a0b801 upgrade deps. 2022-07-31 21:23:46 +02:00
Miroslav Šedivý 2535c01224 add funding. 2022-07-31 14:01:17 +02:00
Miroslav Šedivý b163078446 update favicon, fixes #37. 2022-07-31 14:00:37 +02:00
Miroslav Šedivý e8b875229b Add mux support. (#58)
* add mux support.

* usemux to mux.

* lint fix.

* dev script mux.

* add mux to readme.
2022-07-31 13:52:41 +02:00
Aaron 3d787ab67f Add Vivaldi to drop-down menu (#64) 2022-06-20 06:35:52 +02:00
Miroslav Šedivý 217906cbf8 fix path. 2022-04-09 19:01:45 +02:00
Miroslav Šedivý 8800c023db refactor server manager. (#57) 2022-04-09 18:08:48 +02:00
Miroslav Šedivý e31841246b refactor instance url. (#56) 2022-04-09 17:59:57 +02:00
Miroslav Šedivý 2ca8e9371b minor changes. 2022-04-09 17:25:15 +02:00
Miroslav Šedivý 06ea90b994 extract pull logic to own manager. (#54) 2022-04-09 17:17:28 +02:00
Miroslav Šedivý 4708fb9371 rename module to full github path. (#53) 2022-04-09 17:03:01 +02:00
PanIntegralus 2622586f11 networks.default.attachable must be a boolean (#51) 2022-03-28 08:32:44 +02:00
Gustavo Brian 90d967c5bd Added NEKO_ROOMS_NEKO_PRIVILEGED_IMAGES setting to run privileged images (#49) 2022-03-24 19:21:48 +01:00
Miroslav Šedivý 9b45bdcfc3 vncviewer replace with remmina. 2022-03-19 20:08:39 +01:00
Miroslav Šedivý 8360096ae6 Merge pull request #43 from m1k1o/custom-labels
Add instance labels
2022-02-27 21:47:37 +01:00
Miroslav Šedivý afe75adcf1 Merge branch 'master' into custom-labels 2022-02-27 21:47:03 +01:00
Miroslav Šedivý 62b7ee7059 fix path prefix docs. 2022-02-27 21:45:37 +01:00
Miroslav Šedivý b9514ac1fe rename instaance path prefix. 2022-02-27 21:45:37 +01:00
Miroslav Šedivý 8e0623ee45 add optional path prefix. 2022-02-27 21:45:37 +01:00
Miroslav Šedivý 0ae6c2002d remove service label from docs. 2022-02-27 14:09:25 +01:00
Miroslav Šedivý 9e99803d89 explicitly add service for default traefik router. 2022-02-27 14:08:27 +01:00
Miroslav Šedivý 4a8fa30c2a instance labels -> labels. 2022-02-27 14:05:24 +01:00
Miroslav Šedivý 05dda5935c add labels to docs. 2022-02-26 19:43:09 +01:00
Miroslav Šedivý 0edf9ed8dc add API to traefik dev. 2022-02-26 19:43:00 +01:00
Miroslav Šedivý eeeeca6702 fix labels. 2022-02-26 19:42:41 +01:00
Miroslav Šedivý 62b68419de add instance labels. 2022-02-26 19:11:46 +01:00
Miroslav Šedivý 6f4a9429a8 update docs #38. 2022-02-12 13:37:58 +01:00
Miroslav Šedivý bd3fd98a74 decrease docker-compose version. 2022-02-11 21:22:59 +01:00
Miroslav Šedivý c74aa53ca7 apply env file. 2022-02-11 21:20:23 +01:00
75 changed files with 4250 additions and 2856 deletions
+1
View File
@@ -0,0 +1 @@
github: [ m1k1o ]
+2 -2
View File
@@ -17,7 +17,7 @@ RUN npm run build
#
# STAGE 2: build executable binary
#
FROM golang:1.17-buster as builder
FROM golang:1.18-buster as builder
WORKDIR /app
COPY . .
@@ -33,7 +33,7 @@ COPY --from=frontend /src/dist/ /var/www
ENV DOCKER_API_VERSION=1.39
ENV NEKO_ROOMS_BIND=:8080
ENV NEKO_ROOMS_STATIC=/var/www
ENV NEKO_ROOMS_ADMIN_STATIC=/var/www
EXPOSE 8080
+36 -1
View File
@@ -220,7 +220,27 @@ paths:
$ref: '#/components/schemas/RoomEntry'
'500':
description: Internal server error
/api/rooms/{roomId}/snapshot:
post:
tags:
- rooms
summary: Creates a room snapshot
operationId: roomSnapshot
parameters:
- in: path
name: roomId
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/RoomSnapshot'
'500':
description: Internal server error
/api/pull:
get:
tags:
@@ -282,6 +302,9 @@ components:
storage_enabled:
type: boolean
example: true
uses_mux:
type: boolean
example: true
RoomEntry:
type: object
@@ -304,6 +327,7 @@ components:
max_connections:
type: number
example: 10
description: 0 when using mux
running:
type: boolean
example: true
@@ -358,6 +382,7 @@ components:
max_connections:
type: number
default: 10
description: 0 when using mux
user_pass:
type: string
example: neko
@@ -472,6 +497,16 @@ components:
type: boolean
example: false
RoomSnapshot:
type: object
properties:
id:
type: string
example: bc04dace10
snapshot:
type: string
example: image/tag:20220201102022
BrowserPolicy:
type: object
properties:
+10 -50
View File
@@ -16,70 +16,25 @@ Simple room management system for [n.eko](https://github.com/m1k1o/neko). Self h
<img src="https://github.com/m1k1o/neko-rooms/raw/master/docs/neko.gif" alt="n.eko">
</div>
## Zero-knowledge installation
## Zero-knowledge installation (with HTTPS and Traefik)
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!
- Rent a VPS with public IP and OS Ubuntu.
- Get a domain name pointing to your IP (you can even get some for free).
- Run install script and follow instructions.
- Secure using HTTPs thanks to Let's Encrypt and Traefik.
```bash
wget -O neko-rooms.sh https://raw.githubusercontent.com/m1k1o/neko-rooms/master/install
wget -O neko-rooms.sh https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/install
sudo bash neko-rooms.sh
```
## How to start
You need to have installed `Docker` and `docker-compose`. You need to have a custom domain pointing to your server's IP.
If you want to use Traefik as reverse proxy, visit [installation guide for traefik as reverse proxy](./traefik/).
You can watch installation video provided by *Dr R1ck*:
https://www.youtube.com/watch?v=cCmnw-pq0gA
### Installation guide
You only need `.env.example`, `docker-compose.yml` and `traefik/`.
#### Do I need to use traefik?
- Traefik needs to be used to forward traffic to the rooms. You can put nginx in front of it, but not replace it.
- See example configuration for [nginx](docs/nginx).
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`.
### Step 1
Copy `.env.example` to `.env` and customize.
```bash
cp .env.example .env
```
### Step 2
Create `usersfile` with your users:
```bash
touch traefik/usersfile
```
And add as many users as you like:
```bash
echo $(htpasswd -nb user password) >> traefik/usersfile
```
### Step 3 (HTTPS only)
Create `acme.json`
```bash
touch traefik/acme.json
chmod 600 traefik/acme.json
```
Update your email in `traefik/traefik.yml`.
Otherwise modify variables in `docker-compose.yml` and just run `docker-compose up -d`.
### Download images / update
@@ -106,12 +61,17 @@ You might have encountered this error:
If you didn't specify storage yet, you can do it using [this tutorial](./docs/storage.md).
### Docs
For more information visit [docs](./docs).
### Roadmap:
- [x] add GUI
- [x] add HTTPS support
- [x] add authentication provider for traefik
- [x] allow specifying custom ENV variables
- [x] allow mounting directories for persistent data
- [x] optionally remove Traefik as dependency
- [ ] add upgrade button
- [ ] auto pull images, that do not exist
- [ ] add bearer token to for API
+1587 -1347
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -9,38 +9,38 @@
},
"dependencies": {
"axios": "^0.21.4",
"core-js": "^3.20.3",
"moment": "^2.29.1",
"vue": "^2.6.14",
"core-js": "^3.24.1",
"moment": "^2.29.4",
"vue": "^2.7.8",
"vue-class-component": "^7.2.3",
"vue-property-decorator": "^9.1.2",
"vue-router": "^3.5.3",
"vue-router": "^3.5.4",
"vue-sweetalert2": "^4.3.1",
"vuetify": "^2.6.3",
"vuetify": "^2.6.8",
"vuex": "^3.4.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "^4.5.15",
"@vue/cli-plugin-eslint": "^4.5.15",
"@vue/cli-plugin-router": "^4.5.15",
"@vue/cli-plugin-typescript": "^4.5.15",
"@vue/cli-plugin-vuex": "^4.5.15",
"@vue/cli-service": "^4.5.15",
"@vue/cli-plugin-babel": "^4.5.19",
"@vue/cli-plugin-eslint": "^4.5.19",
"@vue/cli-plugin-router": "^4.5.19",
"@vue/cli-plugin-typescript": "^4.5.19",
"@vue/cli-plugin-vuex": "^4.5.19",
"@vue/cli-service": "^4.5.19",
"@vue/eslint-config-standard": "^5.1.2",
"@vue/eslint-config-typescript": "^5.0.2",
"eslint": "^6.7.2",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.49.0",
"sass-loader": "^10.2.1",
"sass": "^1.54.0",
"sass-loader": "^10.3.1",
"typescript": "^3.9.10",
"vue-cli-plugin-vuetify": "~2.2.2",
"vue-template-compiler": "^2.6.14",
"vuetify-loader": "^1.7.3"
"vue-template-compiler": "^2.7.8",
"vuetify-loader": "^1.9.2"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -7,6 +7,7 @@
<title>neko-rooms</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="icon" type="image/x-icon" sizes="16x16" href="favicon.ico">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
</head>
+96
View File
@@ -590,6 +590,36 @@ export interface RoomsConfig {
* @memberof RoomsConfig
*/
'storage_enabled'?: boolean;
/**
*
* @type {boolean}
* @memberof RoomsConfig
*/
'uses_mux'?: boolean;
}
/**
*
* @export
* @interface RoomSnapshot
*/
export interface RoomSnapshot {
/**
*
* @type {string}
* @memberof RoomSnapshot
*/
'id'?: string;
/**
*
* @type {string}
* @memberof RoomSnapshot
*/
'name'?: string;
/**
*
* @type {string}
* @memberof RoomSnapshot
*/
}
/**
@@ -1248,6 +1278,39 @@ export const RoomsApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Stop room
* @param {string} roomId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
roomSnapshot: async (roomId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'roomId' is not null or undefined
assertParamExists('roomSnapshot', 'roomId', roomId)
const localVarPath = `/api/rooms/{roomId}/snapshot`
.replace(`{${"roomId"}}`, encodeURIComponent(String(roomId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -1376,6 +1439,17 @@ export const RoomsApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.roomsList(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @summary Creates a room snapshot
* @param {string} roomId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async roomSnapshot(roomId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RoomEntry>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.roomSnapshot(roomId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
@@ -1485,6 +1559,16 @@ export const RoomsApiFactory = function (configuration?: Configuration, basePath
roomsList(options?: any): AxiosPromise<Array<RoomEntry>> {
return localVarFp.roomsList(options).then((request) => request(axios, basePath));
},
/**
*
* @summary Creates a room snapshot
* @param {string} roomId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
roomSnapshot(roomId: string, options?: any): AxiosPromise<RoomEntry> {
return localVarFp.roomSnapshot(roomId, options).then((request) => request(axios, basePath));
},
};
};
@@ -1613,6 +1697,18 @@ export class RoomsApi extends BaseAPI {
public roomsList(options?: AxiosRequestConfig) {
return RoomsApiFp(this.configuration).roomsList(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Creates a room snapshot
* @param {string} roomId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof RoomsApi
*/
public roomSnapshot(roomId: string, options?: AxiosRequestConfig) {
return RoomsApiFp(this.configuration).roomSnapshot(roomId, options).then((request) => request(this.axios, this.basePath));
}
}
+1 -3
View File
@@ -54,9 +54,7 @@
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
@Component({
components: {}
})
@Component
export default class Pull extends Vue {
private dialog = false
private loading = false
+11 -3
View File
@@ -12,9 +12,9 @@ import { Vue, Component, Prop } from 'vue-property-decorator'
@Component
export default class RoomActionBtn extends Vue {
@Prop(String) readonly action: string | undefined
@Prop(String) readonly roomId: string | undefined
@Prop(Boolean) readonly disabled: boolean | undefined
@Prop(String) readonly action!: string
@Prop(String) readonly roomId!: string
@Prop(Boolean) readonly disabled!: boolean
private loading = false
@@ -61,6 +61,14 @@ export default class RoomActionBtn extends Vue {
color: 'red',
icon: 'mdi-trash-can-outline',
}
case 'snapshot':
return {
dispatch: 'ROOMS_SNAPSHOT',
msg: 'Room snapshot created!',
tooltip: 'Snapshot',
color: 'blue',
icon: 'mdi-content-save',
}
}
return undefined
+48 -26
View File
@@ -25,10 +25,10 @@
:rotate="270"
:size="100"
:width="15"
:value="(stats.connections / settings.max_connections) * 100"
:value="settings.max_connections == 0 ? 100 : ((stats.connections / settings.max_connections) * 100)"
color="blue"
>
{{ stats.connections }} / {{ settings.max_connections }}
{{ stats.connections }} <template v-if="settings.max_connections > 0">/ {{ settings.max_connections }}</template>
</v-progress-circular>
</div>
</v-col>
@@ -69,8 +69,27 @@
</v-simple-table>
</v-col>
</v-row>
<template v-if="statsErr && room.running">
<v-alert
border="left"
type="info"
v-if="room.status.includes('starting')"
>
<p><strong>Room stats are not available.</strong></p>
<p class="mb-0">Because room is currently starting. They will be availalbe soon.</p>
</v-alert>
<v-alert
border="left"
type="warning"
v-else
>
<p><strong>Room stats are not available.</strong></p>
<p class="mb-0">{{ statsErr }}</p>
</v-alert>
</template>
<div class="text-center mt-3">
<v-btn @click="LoadStats" :loading="statsLoading">Reload</v-btn>
<v-btn @click="LoadStats" :loading="statsLoading" :disabled="!room.running">Reload</v-btn>
</div>
<div class="my-3 headline">Main settings</div>
@@ -80,26 +99,15 @@
<tr><th style="width:50%;"> Name </th><td>{{ settings.name }}</td></tr>
<tr><th> Neko image </th><td>{{ settings.neko_image }}</td></tr>
<tr><th> User password </th><td>
<v-btn @click="showUserPass = !showUserPass" icon small><v-icon small>{{ showUserPass ? 'mdi-eye' : 'mdi-eye-off' }}</v-icon></v-btn>
<span class="mx-2">{{ showUserPass ? settings.user_pass : '****' }}</span>
<v-tooltip top v-if="room">
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" :disabled="!room.running" :href="room.url + '?pwd=' + encodeURIComponent(settings.user_pass)" target="_blank" small> <v-icon small>mdi-open-in-new</v-icon></v-btn>
</template>
<span>Invite link for users</span>
</v-tooltip>
<RoomLink :roomId="roomId" :password="settings.user_pass" label="invite link for users" />
</td></tr>
<tr><th> Admin password </th><td>
<v-btn @click="showAdminPass = !showAdminPass" icon small><v-icon small>{{ showAdminPass ? 'mdi-eye' : 'mdi-eye-off' }}</v-icon></v-btn>
<span class="mx-2">{{ showAdminPass ? settings.admin_pass : '****' }}</span>
<v-tooltip bottom v-if="room">
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" :disabled="!room.running" :href="room.url + '?pwd=' + encodeURIComponent(settings.admin_pass)" target="_blank" small> <v-icon small>mdi-open-in-new</v-icon></v-btn>
</template>
<span>Invite link for admins</span>
</v-tooltip>
<RoomLink :roomId="roomId" :password="settings.admin_pass" label="invite link for admins" />
</td></tr>
<tr v-if="!usesMux"><th> Max connections </th><td>
<template v-if="settings.max_connections > 0">{{ settings.max_connections }}</template>
<i v-else>not limited because uses mux</i>
</td></tr>
<tr><th> Max connections </th><td>{{ settings.max_connections }}</td></tr>
<tr><th> Control protection </th><td>{{ settings.control_protection }}</td></tr>
<tr><th> Implicit Control </th><td>{{ settings.implicit_control }}</td></tr>
</tbody>
@@ -196,6 +204,7 @@
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import RoomLink from './RoomLink.vue'
import {
RoomStats,
@@ -204,19 +213,21 @@ import {
RoomEntry,
} from '@/api/index'
@Component
@Component({
components: {
RoomLink,
}
})
export default class RoomInfo extends Vue {
@Prop(String) readonly roomId: string | undefined
@Prop(String) readonly roomId!: string
private statsLoading = false
private statsErr = ""
private stats: RoomStats | null = null
private settingsLoading = false
private settings: RoomSettings | null = null
private showUserPass = false
private showAdminPass = false
get room(): RoomEntry {
return this.$store.state.rooms.find(({ id }: RoomEntry) => id == this.roomId)
}
@@ -224,12 +235,15 @@ export default class RoomInfo extends Vue {
@Watch('roomId', { immediate: true })
async SetRoomId(roomId: string) {
this.stats = null
this.statsErr = ""
this.settings = null
this.settingsLoading = true
try {
this.settings = await this.$store.dispatch('ROOMS_SETTINGS', roomId)
this.LoadStats()
if (this.room.running) {
this.LoadStats()
}
} catch(e) {
if (e.response) {
this.$swal({
@@ -249,6 +263,7 @@ export default class RoomInfo extends Vue {
}
}
@Watch('room.status')
async LoadStats() {
this.statsLoading = true
@@ -269,11 +284,18 @@ export default class RoomInfo extends Vue {
return 0
})
this.stats = stats
this.statsErr = ""
} catch (e: any) {
this.statsErr = e
} finally {
this.statsLoading = false
}
}
get usesMux() {
return this.$store.state.roomsConfig.uses_mux
}
get allBrowserExtensions() {
if (!this.settings?.browser_policy?.type) return {}
+93
View File
@@ -0,0 +1,93 @@
<template>
<div class="d-flex align-center">
<v-text-field
ref="input"
:append-icon="showPass ? 'mdi-eye' : 'mdi-eye-off'"
:value="showPass ? password : '*****'"
@click:append="togglePass"
@click="showPass && selectAll()"
@focus="showPass && selectAll()"
hide-details
outlined
readonly
dense
></v-text-field>
<v-spacer />
<v-tooltip bottom v-if="room">
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" :disabled="!room.running" :href="url" target="_blank" small>
<v-icon small>mdi-open-in-new</v-icon>
</v-btn>
</template>
<span>{{ label }}</span>
</v-tooltip>
<v-spacer />
<v-tooltip bottom v-if="room">
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" :disabled="!room.running" @click="copyToClipboard" small>
<v-icon small v-if="copied">mdi-clipboard-check-multiple</v-icon>
<v-icon small v-else>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</template>
<span>copy link to clipboard</span>
</v-tooltip>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import {
RoomEntry,
} from '@/api/index'
@Component
export default class RoomLink extends Vue {
@Prop(String) readonly roomId!: string
@Prop(String) readonly password!: string
@Prop(String) readonly label!: string
private showPass = false
private copied = false
private copiedTimeout = 0
get room(): RoomEntry {
return this.$store.state.rooms.find(({ id }: RoomEntry) => id == this.roomId)
}
get url() {
return this.room.url + '?pwd=' + encodeURIComponent(this.password)
}
togglePass() {
this.showPass = !this.showPass
if (this.showPass) {
setTimeout(() => {
this.selectAll()
}, 0)
}
}
selectAll() {
this.$refs.input.$el.querySelector('input').select();
}
copyToClipboard() {
if (this.copiedTimeout) {
clearInterval(this.copiedTimeout)
}
navigator.clipboard.writeText(this.url)
this.copied = true
this.copiedTimeout = setTimeout(() => {
this.copied = false
this.copiedTimeout = 0
}, 3000)
}
}
</script>
+31 -7
View File
@@ -56,7 +56,28 @@
</v-col>
</v-row>
<v-row align="center">
<!-- if uses mux vs. old logic -->
<v-row align="center" v-if="usesMux">
<v-col class="pt-0">
<v-checkbox
v-model="data.control_protection"
label="Enable control protection"
hide-details
class="shrink ml-2 mt-0"
></v-checkbox>
<div style="margin-left: 41px;"><i>Users can gain control only if at least one admin is in the room.</i></div>
</v-col>
<v-col class="pt-0">
<v-checkbox
v-model="data.implicit_control"
label="Enable implicit control"
hide-details
class="shrink ml-2 mt-0"
></v-checkbox>
<div style="margin-left: 41px;"><i>Users do not need to request control prior usage.</i></div>
</v-col>
</v-row>
<v-row align="center" v-else>
<v-col class="pt-0">
<v-text-field
label="Max connections"
@@ -482,6 +503,10 @@ export default class RoomsCreate extends Vue {
return this.$store.state.roomsConfig.storage_enabled
}
get usesMux() {
return this.$store.state.roomsConfig.uses_mux
}
get videoCodecs() {
return this.$store.state.videoCodecs
}
@@ -526,14 +551,13 @@ export default class RoomsCreate extends Vue {
const config = this.browserPolicyConfig
if (!config) return []
return this.$store.state.browserPolicyExtensions.map(({ text, value }: {
return this.$store.state.browserPolicyExtensions.map(({ text, value: values }: {
text: string;
value: Record<BrowserPolicyTypeEnum, BrowserPolicyExtension>;
}) => {
return {
text,
value: value[config.type as BrowserPolicyTypeEnum],
}
const value = values[config.type as BrowserPolicyTypeEnum]
if (!value) return undefined
return { text, value }
})
}
@@ -579,7 +603,7 @@ export default class RoomsCreate extends Vue {
// eslint-disable-next-line
admin_pass: this.data.admin_pass || randomPassword(),
// eslint-disable-next-line
max_connections: Number(this.data.max_connections),
max_connections: Number(this.data.max_connections), // ignored when uses mux
// eslint-disable-next-line
control_protection: Boolean(this.data.control_protection),
// eslint-disable-next-line
+32 -26
View File
@@ -33,11 +33,11 @@
</v-tooltip>
</template>
<template v-slot:[`item.max_connections`]="{ item }">
<span v-if="item.max_connections">{{ item.max_connections }}</span>
<i v-else>--not-specified--</i>
<span v-if="item.max_connections > 0">{{ item.max_connections }}</span>
<i v-else>uses mux</i>
</template>
<template v-slot:[`item.status`]="{ item }">
<v-chip :color="item.running ? 'green' : 'red'" dark small> {{ item.status }} </v-chip>
<v-chip :color="item.running ? (item.status.includes('unhealthy') ? 'warning' : 'green') : 'red'" dark small> {{ item.status }} </v-chip>
</template>
<template v-slot:[`item.created`]="{ item }">
{{ item.created | timeago }}
@@ -46,6 +46,7 @@
<RoomActionBtn action="start" :roomId="item.id" :disabled="item.running" />
<RoomActionBtn action="stop" :roomId="item.id" :disabled="!item.running" />
<RoomActionBtn action="restart" :roomId="item.id" :disabled="!item.running" />
<RoomActionBtn action="snapshot" :roomId="item.id" :disabled="!item.running" />
</template>
<template v-slot:[`item.destroy`]="{ item }">
<RoomActionBtn action="remove" :roomId="item.id" />
@@ -89,29 +90,34 @@ export default class RoomsList extends Vue {
private roomId = ''
private roomLoading = [] as Array<string>
private headers = [
{
text: 'Deployment',
value: 'url',
sortable: false,
},
{ text: 'Name', value: 'name' },
{ text: 'Max connections', value: 'max_connections' },
{ text: 'Neko image', value: 'neko_image' },
{ text: 'Status', value: 'status' },
{ text: 'Created', value: 'created' },
{
text: 'Actions',
value: 'actions',
sortable: false,
},
{
text: 'Destroy',
value: 'destroy',
align: 'end',
sortable: false,
},
]
get headers() {
return [
{
text: 'Deployment',
value: 'url',
sortable: false,
},
{ text: 'Name', value: 'name' },
// when using mux we don't have max connections
...(!this.$store.state.roomsConfig.uses_mux ? [
{ text: 'Max connections', value: 'max_connections' }
] : []),
{ text: 'Neko image', value: 'neko_image' },
{ text: 'Status', value: 'status' },
{ text: 'Created', value: 'created' },
{
text: 'Actions',
value: 'actions',
sortable: false,
},
{
text: 'Destroy',
value: 'destroy',
align: 'end',
sortable: false,
},
]
}
get rooms() {
return this.$store.state.rooms
+12
View File
@@ -11,6 +11,7 @@ import {
RoomsApi,
DefaultApi,
PullStatus,
RoomSnapshot,
} from '@/api/index'
import { state, State } from './state'
@@ -61,6 +62,9 @@ export default new Vuex.Store({
PULL_STATUS(state: State, pullStatus: PullStatus) {
Vue.set(state, 'pullStatus', pullStatus)
},
ROOMS_SNAPSHOT_ADD(state: State, snapshot: RoomSnapshot) {
Vue.set(state, 'snapshot', snapshot)
},
},
actions: {
async ROOMS_CONFIG({ commit }: ActionContext<State, State>) {
@@ -135,6 +139,14 @@ export default new Vuex.Store({
const res = await defaultApi.pullStop()
return res.data
},
async ROOMS_SNAPSHOT({ commit }: ActionContext<State, State>, roomId: string) {
const res = await roomsApi.roomSnapshot(roomId)
commit('ROOMS_SNAPSHOT_ADD', {
roomId: roomId,
data: res.data
});
return res.data
},
},
modules: {
}
+98 -4
View File
@@ -177,6 +177,92 @@ export const state = {
} as BrowserPolicyExtension,
},
},
{
text: 'SponsorBlock for YouTube',
value: {
[BrowserPolicyTypeEnum.firefox]: {
id: 'sponsorBlocker@ajay.app',
url: 'https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi',
} as BrowserPolicyExtension,
[BrowserPolicyTypeEnum.chromium]: {
id: 'mnjggcdmjocbbbhaepdhchncahnbgone',
} as BrowserPolicyExtension,
},
},
{
text: 'SoundFixer',
value: {
[BrowserPolicyTypeEnum.firefox]: {
id: 'soundfixer@unrelenting.technology',
url: 'https://addons.mozilla.org/firefox/downloads/latest/soundfixer/latest.xpi',
} as BrowserPolicyExtension,
[BrowserPolicyTypeEnum.chromium]: {
id: 'mbhbddecpoendcifccfckjkigbinefkg',
} as BrowserPolicyExtension,
},
},
{
text: 'Native MPEG-Dash + HLS Playback',
value: {
[BrowserPolicyTypeEnum.firefox]: {
id: 'native-dash-hls@cavar.net',
url: 'https://addons.mozilla.org/firefox/downloads/latest/native-mpeg-dash-hls-playback/latest.xpi',
} as BrowserPolicyExtension,
[BrowserPolicyTypeEnum.chromium]: {
id: 'cjfbmleiaobegagekpmlhmaadepdeedn',
} as BrowserPolicyExtension,
},
},
{
text: 'Google Input Tools',
value: {
// not available for firefox
[BrowserPolicyTypeEnum.chromium]: {
id: 'mclkkofklkfljcocdinagocijmpgbhab',
} as BrowserPolicyExtension,
},
},
//
// VPN
//
{
text: 'Windscribe - Free Proxy and Ad Blocker',
value: {
[BrowserPolicyTypeEnum.firefox]: {
id: '@windscribeff',
url: 'https://addons.mozilla.org/firefox/downloads/latest/windscribe/latest.xpi',
} as BrowserPolicyExtension,
[BrowserPolicyTypeEnum.chromium]: {
id: 'hnmpcagpplmpfojmgmnngilcnanddlhb',
} as BrowserPolicyExtension,
},
},
{
text: 'Private Internet Access',
value: {
[BrowserPolicyTypeEnum.firefox]: {
id: '{3e4d2037-d300-4e95-859d-3cba866f46d3}',
url: 'https://addons.mozilla.org/firefox/downloads/latest/private-internet-access-ext/latest.xpi',
} as BrowserPolicyExtension,
[BrowserPolicyTypeEnum.chromium]: {
id: 'jplnlifepflhkbkgonidnobkakhmpnmh',
} as BrowserPolicyExtension,
},
},
{
text: 'SetupVPN - Lifetime Free VPN',
value: {
[BrowserPolicyTypeEnum.firefox]: {
id: '@setupvpncom',
url: 'https://addons.mozilla.org/firefox/downloads/latest/setupvpn/latest.xpi',
} as BrowserPolicyExtension,
[BrowserPolicyTypeEnum.chromium]: {
id: 'oofgbpoabipfcfjapgnbbjjaenockbdp',
} as BrowserPolicyExtension,
},
},
{
text: 'NordVPN',
value: {
@@ -190,14 +276,22 @@ export const state = {
},
},
{
text: 'SponsorBlock for YouTube',
text: 'Surfshark VPN Extension',
value: {
[BrowserPolicyTypeEnum.firefox]: {
id: 'sponsorBlocker@ajay.app',
url: 'https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi',
id: '{732216ec-0dab-43bb-ac85-4b5e1977599d}',
url: 'https://addons.mozilla.org/firefox/downloads/latest/surfshark-vpn-proxy/latest.xpi',
} as BrowserPolicyExtension,
[BrowserPolicyTypeEnum.chromium]: {
id: 'mnjggcdmjocbbbhaepdhchncahnbgone',
id: 'ailoabdmgclmfmhdagmlohpjlbpffblp',
} as BrowserPolicyExtension,
},
},
{
text: 'Hola VPN - The Website Unblocker',
value: {
[BrowserPolicyTypeEnum.chromium]: {
id: 'gkojfkhlekighikafcpjkiklfbnlmeio',
} as BrowserPolicyExtension,
},
},
+1 -1
View File
@@ -101,7 +101,7 @@ export default class Home extends Vue {
get usedConnections() {
// eslint-disable-next-line
return this.$store.state.rooms.reduce((sum: number, { max_connections }: RoomEntry) => sum + (max_connections || 0), 0)
return this.$store.state.rooms.reduce((sum: number, { max_connections }: RoomEntry) => sum + (max_connections || 1/* 0 when using mux */), 0)
}
async LoadRooms() {
+4 -4
View File
@@ -5,13 +5,13 @@ import (
"github.com/rs/zerolog/log"
"m1k1o/neko_rooms"
"m1k1o/neko_rooms/cmd"
"m1k1o/neko_rooms/internal/utils"
nekoRooms "github.com/m1k1o/neko-rooms"
"github.com/m1k1o/neko-rooms/cmd"
"github.com/m1k1o/neko-rooms/internal/utils"
)
func main() {
fmt.Print(utils.Colorf(neko_rooms.Header, "server", neko_rooms.Service.Version))
fmt.Print(utils.Colorf(nekoRooms.Header, "server", nekoRooms.Service.Version))
if err := cmd.Execute(); err != nil {
log.Panic().Err(err).Msg("failed to execute command")
}
+13 -8
View File
@@ -15,7 +15,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"m1k1o/neko_rooms"
nekoRooms "github.com/m1k1o/neko-rooms"
)
func Execute() error {
@@ -23,10 +23,10 @@ func Execute() error {
}
var root = &cobra.Command{
Use: "neko_rooms",
Short: "neko_rooms server",
Long: `neko_rooms server`,
Version: neko_rooms.Service.Version.String(),
Use: "neko-rooms",
Short: "neko-rooms server",
Long: `neko-rooms server`,
Version: nekoRooms.Service.Version.String(),
}
func init() {
@@ -49,6 +49,7 @@ func init() {
logs := filepath.Join(".", "logs")
if runtime.GOOS == "linux" {
// TODO: Change to neko-rooms.
logs = "/var/log/neko_rooms"
}
@@ -56,9 +57,11 @@ func init() {
os.Mkdir(logs, os.ModePerm)
}
// TODO: Change to neko-rooms.
latest := filepath.Join(logs, "neko_rooms-latest.log")
_, err := os.Stat(latest)
if err == nil {
// TODO: Change to neko-rooms.
err = os.Rename(latest, filepath.Join(logs, "neko_rooms."+time.Now().Format("2006-01-02T15-04-05Z07-00")+".log"))
if err != nil {
log.Panic().Err(err).Msg("failed to rotate log file")
@@ -85,10 +88,12 @@ func init() {
viper.SetConfigFile(config) // Use config file from the flag.
} else {
if runtime.GOOS == "linux" {
// TODO: Change to neko-rooms.
viper.AddConfigPath("/etc/neko_rooms/")
}
viper.AddConfigPath(".")
// TODO: Change to config.
viper.SetConfigName("neko_rooms")
}
@@ -118,12 +123,12 @@ func init() {
logger.Info().Msg("preflight complete")
}
neko_rooms.Service.Configs.Root.Set()
nekoRooms.Service.Configs.Root.Set()
})
if err := neko_rooms.Service.Configs.Root.Init(root); err != nil {
if err := nekoRooms.Service.Configs.Root.Init(root); err != nil {
log.Panic().Err(err).Msg("unable to run root command")
}
root.SetVersionTemplate(neko_rooms.Service.Version.Details())
root.SetVersionTemplate(nekoRooms.Service.Version.Details())
}
+8 -9
View File
@@ -4,29 +4,28 @@ import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"m1k1o/neko_rooms"
"m1k1o/neko_rooms/internal/config"
nekoRooms "github.com/m1k1o/neko-rooms"
"github.com/m1k1o/neko-rooms/internal/config"
)
func init() {
command := &cobra.Command{
Use: "serve",
Short: "serve neko_rooms server",
Long: `serve neko_rooms server`,
Run: neko_rooms.Service.ServeCommand,
Short: "serve neko-rooms server",
Long: `serve neko-rooms server`,
Run: nekoRooms.Service.ServeCommand,
}
configs := []config.Config{
neko_rooms.Service.Configs.Server,
neko_rooms.Service.Configs.API,
neko_rooms.Service.Configs.Room,
nekoRooms.Service.Configs.Server,
nekoRooms.Service.Configs.Room,
}
cobra.OnInitialize(func() {
for _, cfg := range configs {
cfg.Set()
}
neko_rooms.Service.Preflight()
nekoRooms.Service.Preflight()
})
for _, cfg := range configs {
+21 -14
View File
@@ -1,24 +1,31 @@
#!/bin/sh
cd "$(dirname "$0")"
if [ ! -f "../.env" ]
then
echo "../.env file not found!"
exit 1
if [ -z $APP_PORT ]; then
APP_PORT="8080"
fi
export $(cat ../.env | sed 's/#.*//g' | xargs)
if [ -z $APP_HOST ]; then
for i in $(ifconfig -l 2>/dev/null); do
APP_HOST=$(ipconfig getifaddr $i)
if [ ! -z $APP_HOST ]; then
break
fi
done
if [ -z $APP_HOST ]; then
APP_HOST=$(hostname -i 2>/dev/null)
fi
fi
echo "Using app port: ${APP_PORT}"
echo "Using IP address: ${APP_HOST}"
docker run --rm -it \
--name="neko_rooms_client" \
-v "${PWD}/../client:/app" \
-e "TZ=${TZ}" \
--net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \
-l "traefik.enable=true" \
-l "traefik.http.services.neko-rooms-client-fe.loadbalancer.server.port=8080" \
-l "traefik.http.routers.neko-rooms-client.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \
-l "traefik.http.routers.neko-rooms-client.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`)" \
-p 8081:8080 \
-e "API_PROXY=http://${APP_HOST}:${APP_PORT}" \
--user="$(id -u):$(id -g)" \
--workdir="/app" \
--volume "${PWD}/../client:/app" \
--entrypoint="npm" \
--workdir="/app" \
node:14 run serve;
+35 -13
View File
@@ -1,13 +1,36 @@
#!/bin/sh
cd "$(dirname "$0")"
if [ ! -f "../.env" ]
then
echo "../.env file not found!"
exit 1
if [ -z $NEKO_ROOMS_PORT ]; then
NEKO_ROOMS_PORT="8080"
fi
export $(cat ../.env | sed 's/#.*//g' | xargs)
if [ -z $NEKO_ROOMS_EPR ]; then
NEKO_ROOMS_EPR="52090-52099"
fi
if [ -z $NEKO_ROOMS_NAT1TO1 ]; then
for i in $(ifconfig -l 2>/dev/null); do
NEKO_ROOMS_NAT1TO1=$(ipconfig getifaddr $i)
if [ ! -z $NEKO_ROOMS_NAT1TO1 ]; then
break
fi
done
if [ -z $NEKO_ROOMS_NAT1TO1 ]; then
NEKO_ROOMS_NAT1TO1=$(hostname -i 2>/dev/null)
fi
fi
NEKO_ROOMS_INSTANCE_NETWORK="neko-rooms-net"
docker network create --attachable "${NEKO_ROOMS_INSTANCE_NETWORK}";
trap on_exit EXIT
on_exit() {
echo "Removing neko-rooms network"
docker network rm "${NEKO_ROOMS_INSTANCE_NETWORK}";
}
DATA_PATH="./data"
mkdir -p "${DATA_PATH}"
@@ -17,23 +40,22 @@ mkdir -p "${EXTERNAL_PATH}"
docker run --rm -it \
--name="neko_rooms_server" \
-p "${NEKO_ROOMS_PORT}:8080" \
-v "`realpath ..`:/app" \
-v "`realpath ${DATA_PATH}`:/data" \
-e "TZ=${TZ}" \
-e "NEKO_ROOMS_MUX=true" \
-e "NEKO_ROOMS_EPR=${NEKO_ROOMS_EPR}" \
-e "NEKO_ROOMS_NAT1TO1=${NEKO_ROOMS_NAT1TO1}" \
-e "NEKO_ROOMS_INSTANCE_URL=http://${NEKO_ROOMS_NAT1TO1}:${NEKO_ROOMS_PORT}/" \
-e "NEKO_ROOMS_INSTANCE_NETWORK=${NEKO_ROOMS_INSTANCE_NETWORK}" \
-e "NEKO_ROOMS_STORAGE_INTERNAL=/data" \
-e "NEKO_ROOMS_STORAGE_EXTERNAL=`realpath ${DATA_PATH}`" \
-e "NEKO_ROOMS_MOUNTS_WHITELIST=`realpath ${EXTERNAL_PATH}`" \
-e "NEKO_ROOMS_TRAEFIK_DOMAIN=${NEKO_ROOMS_TRAEFIK_DOMAIN}" \
-e "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \
-e "NEKO_ROOMS_TRAEFIK_NETWORK=${NEKO_ROOMS_TRAEFIK_NETWORK}" \
-e "NEKO_ROOMS_PATH_PREFIX=/room/" \
-e "NEKO_ROOMS_TRAEFIK_ENABLED=false" \
-e 'DOCKER_API_VERSION=1.39' \
-v "/var/run/docker.sock:/var/run/docker.sock" \
--net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \
-l "traefik.enable=true" \
-l "traefik.http.services.neko-rooms-server-fe.loadbalancer.server.port=8080" \
-l "traefik.http.routers.neko-rooms-server.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \
-l "traefik.http.routers.neko-rooms-server.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`) && PathPrefix(\`/api\`)" \
--net="${NEKO_ROOMS_INSTANCE_NETWORK}" \
--entrypoint="/app/bin/neko_rooms" \
neko_rooms_img serve --bind :8080;
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
cd "$(dirname "$0")"
set -e
docker run --rm -it \
-v "${PWD}/../../:/app" \
--entrypoint="go" \
neko_rooms_img build -o bin/neko_rooms cmd/neko_rooms/main.go
./start
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
cd "$(dirname "$0")"
if [ ! -f ".env" ]
then
echo ".env file not found!"
exit 1
fi
export $(cat .env | sed 's/#.*//g' | xargs)
docker run --rm -it \
--name="neko_rooms_client" \
-v "${PWD}/../../client:/app" \
-e "TZ=${TZ}" \
--net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \
-l "traefik.enable=true" \
-l "traefik.http.services.neko-rooms-client-fe.loadbalancer.server.port=8080" \
-l "traefik.http.routers.neko-rooms-client.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \
-l "traefik.http.routers.neko-rooms-client.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`)" \
-l "traefik.http.routers.neko-rooms-client.priority=1" \
--user="$(id -u):$(id -g)" \
--workdir="/app" \
--entrypoint="npm" \
node:14 run serve;
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
cd "$(dirname "$0")"
if [ ! -f ".env" ]
then
echo ".env file not found!"
exit 1
fi
export $(cat .env | sed 's/#.*//g' | xargs)
DATA_PATH="../data"
mkdir -p "${DATA_PATH}"
EXTERNAL_PATH="../ext"
mkdir -p "${EXTERNAL_PATH}"
docker run --rm -it \
--name="neko_rooms_server" \
-v "`realpath ../../`:/app" \
-v "`realpath ${DATA_PATH}`:/data" \
-e "TZ=${TZ}" \
-e "NEKO_ROOMS_EPR=${NEKO_ROOMS_EPR}" \
-e "NEKO_ROOMS_MUX=true" \
-e "NEKO_ROOMS_NAT1TO1=${NEKO_ROOMS_NAT1TO1}" \
-e "NEKO_ROOMS_PATH_PREFIX=/room/" \
-e "NEKO_ROOMS_STORAGE_INTERNAL=/data" \
-e "NEKO_ROOMS_STORAGE_EXTERNAL=`realpath ${DATA_PATH}`" \
-e "NEKO_ROOMS_MOUNTS_WHITELIST=`realpath ${EXTERNAL_PATH}`" \
-e "NEKO_ROOMS_TRAEFIK_ENABLED=true" \
-e "NEKO_ROOMS_TRAEFIK_DOMAIN=${NEKO_ROOMS_TRAEFIK_DOMAIN}" \
-e "NEKO_ROOMS_TRAEFIK_ENTRYPOINT=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \
-e "NEKO_ROOMS_TRAEFIK_NETWORK=${NEKO_ROOMS_TRAEFIK_NETWORK}" \
-e 'DOCKER_API_VERSION=1.39' \
-v "/var/run/docker.sock:/var/run/docker.sock" \
--net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \
-l "traefik.enable=true" \
-l "traefik.http.services.neko-rooms-server-fe.loadbalancer.server.port=8080" \
-l "traefik.http.routers.neko-rooms-server.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \
-l "traefik.http.routers.neko-rooms-server.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`) && PathPrefix(\`/room\`)" \
-l "traefik.http.routers.neko-rooms-server.priority=2" \
-l "traefik.http.routers.neko-rooms-server.service=neko-rooms-server-fe" \
-l "traefik.http.routers.neko-rooms-server-api.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}" \
-l "traefik.http.routers.neko-rooms-server-api.rule=Host(\`${NEKO_ROOMS_TRAEFIK_DOMAIN}\`) && PathPrefix(\`/api\`)" \
-l "traefik.http.routers.neko-rooms-server-api.service=neko-rooms-server-fe" \
--entrypoint="/app/bin/neko_rooms" \
neko_rooms_img serve --bind :8080;
+6 -4
View File
@@ -1,13 +1,13 @@
#!/bin/sh
cd "$(dirname "$0")"
if [ ! -f "../.env" ]
if [ ! -f ".env" ]
then
echo "../.env file not found!"
echo ".env file not found!"
exit 1
fi
export $(cat ../.env | sed 's/#.*//g' | xargs)
export $(cat .env | sed 's/#.*//g' | xargs)
docker network create --attachable "${NEKO_ROOMS_TRAEFIK_NETWORK}";
@@ -21,11 +21,13 @@ on_exit() {
docker run --rm -it \
--name="neko_rooms_traefik" \
-p "${1:-80}:80" \
-v "${PWD}/../:/app" \
-p "8080:8080" \
-v "${PWD}/../../:/app" \
-e "TZ=${TZ}" \
-v "/var/run/docker.sock:/var/run/docker.sock" \
--net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \
traefik:2.4 \
--api.insecure=true \
--providers.docker=true \
--providers.docker.watch=true \
--providers.docker.exposedbydefault=false \
+13 -38
View File
@@ -1,49 +1,24 @@
version: "3.7"
#
# This docker compose needs .env file.
# Copy .env.example to .env and modify.
#
version: "3.5"
networks:
default:
attachable: "true"
name: "${NEKO_ROOMS_TRAEFIK_NETWORK}"
attachable: true
name: "neko-rooms-net"
services:
traefik:
image: "traefik:2.4"
restart: "unless-stopped"
environment:
- "TZ"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/traefik.yml:/etc/traefik/traefik.yml:ro"
- "./traefik/usersfile:/usersfile:ro"
- "./traefik/acme.json:/acme.json"
- "./traefik/config:/config"
neko-rooms:
image: "m1k1o/neko-rooms:latest"
restart: "unless-stopped"
environment:
- "TZ"
- "NEKO_ROOMS_EPR"
- "NEKO_ROOMS_NAT1TO1"
- "NEKO_ROOMS_TRAEFIK_DOMAIN"
- "NEKO_ROOMS_TRAEFIK_ENTRYPOINT"
- "NEKO_ROOMS_TRAEFIK_CERTRESOLVER"
- "NEKO_ROOMS_TRAEFIK_NETWORK"
- "TZ=Europe/Vienna"
- "NEKO_ROOMS_MUX=true"
- "NEKO_ROOMS_EPR=59000-59049"
- "NEKO_ROOMS_NAT1TO1=127.0.0.1" # IP address of your server that is reachable from client
- "NEKO_ROOMS_INSTANCE_URL=http://127.0.0.1:8080/" # external URL
- "NEKO_ROOMS_INSTANCE_NETWORK=neko-rooms-net"
- "NEKO_ROOMS_TRAEFIK_ENABLED=false"
- "NEKO_ROOMS_PATH_PREFIX=/room/"
ports:
- "8080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
labels:
- "traefik.enable=true"
- "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080"
- "traefik.http.routers.neko-rooms.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}"
- "traefik.http.routers.neko-rooms.rule=Host(`${NEKO_ROOMS_TRAEFIK_DOMAIN}`)"
- "traefik.http.routers.neko-rooms.tls=true"
- "traefik.http.routers.neko-rooms.tls.certresolver=${NEKO_ROOMS_TRAEFIK_CERTRESOLVER}"
- "traefik.http.routers.neko-rooms.middlewares=basicauth@file"
+31
View File
@@ -0,0 +1,31 @@
# Docs
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`.
![architecture](./architecture.svg)
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.
`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.
## Connection timeout
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.
## path prefix
Room names are by default put to root directory. If you want to have custom path prefix, you can specify it using env variable:
```
NEKO_ROOMS_PATH_PREFIX=/test/
```
Now room will be available at `example.org/test/<room-name>` instead of `example.org/<room-name>`.
## using mux
When using mux, there will be allocated two ports per room: TCP and UDP port with the same number.
```
NEKO_ROOMS_MUX=true
```
+1
View File
@@ -0,0 +1 @@
<mxfile host="app.diagrams.net" modified="2022-02-12T12:21:05.035Z" agent="5.0 (X11)" etag="vVCYmo1eawPDjs-xukiW" version="16.5.6" type="device"><diagram id="e7aEsMLIEvFOf0PZvSqS" name="Page-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==</diagram></mxfile>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
version: "3"
version: "3.5"
services:
neko-rooms:
+16
View File
@@ -0,0 +1,16 @@
# custom labels
You can add custom lables for every room.
Example: Expose port 3000 from every room under `3000-(room-name)`:
```bash
-e "NEKO_ROOMS_INSTANCE_LABELS=
traefik.http.services.{containerName}-3000-tcp.loadbalancer.server.port=3000
traefik.http.routers.{containerName}-3000-tcp.entrypoints={traefikEntrypoint}
traefik.http.routers.{containerName}-3000-tcp.rule=PathPrefix(\`/3000-{roomName}\`)
traefik.http.middlewares.{containerName}-3000-tcp-prf.stripprefix.prefixes=/3000-{roomName}/
traefik.http.routers.{containerName}-3000-tcp.middlewares={containerName}-3000-tcp-prf
traefik.http.routers.{containerName}-3000-tcp.service={containerName}-3000-tcp
"
```
+27 -29
View File
@@ -1,52 +1,50 @@
module m1k1o/neko_rooms
module github.com/m1k1o/neko-rooms
go 1.17
go 1.18
require (
github.com/Microsoft/go-winio v0.5.1 // indirect
github.com/containerd/containerd v1.5.9 // indirect
github.com/docker/docker v20.10.12+incompatible
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/docker/docker v20.10.17+incompatible
github.com/docker/go-connections v0.4.0
github.com/go-chi/chi v4.1.2+incompatible
github.com/go-chi/cors v1.2.0
github.com/gorilla/mux v1.8.0 // indirect
github.com/magiconair/properties v1.8.5 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/go-chi/cors v1.2.1
github.com/magiconair/properties v1.8.6 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/rs/zerolog v1.26.1
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/afero v1.8.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.3.0
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/rs/zerolog v1.27.0
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.5.0
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.10.1
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350 // indirect
google.golang.org/grpc v1.44.0 // indirect
github.com/spf13/viper v1.12.0
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462 // indirect
golang.org/x/sys v0.0.0-20220731174439-a90be440212d // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/ini.v1 v1.66.3 // indirect
gopkg.in/ini.v1 v1.66.6 // indirect
)
require (
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect
github.com/pelletier/go-toml/v2 v2.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/subosito/gotenv v1.4.0 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.0.3 // indirect
)
+134 -587
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -5,21 +5,20 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko_rooms/internal/config"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
type ApiManagerCtx struct {
logger zerolog.Logger
rooms types.RoomManager
conf *config.API
pull types.PullManager
}
func New(roomManager types.RoomManager, conf *config.API) *ApiManagerCtx {
func New(rooms types.RoomManager, pull types.PullManager) *ApiManagerCtx {
return &ApiManagerCtx{
logger: log.With().Str("module", "api").Logger(),
rooms: roomManager,
conf: conf,
rooms: rooms,
pull: pull,
}
}
@@ -58,5 +57,6 @@ func (manager *ApiManagerCtx) Mount(r chi.Router) {
r.Post("/stop", manager.roomGenericAction(manager.rooms.Stop))
r.Post("/restart", manager.roomGenericAction(manager.rooms.Restart))
r.Post("/recreate", manager.roomRecreate)
r.Post("/snapshot", manager.roomSnapshot)
})
}
+5 -5
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"net/http"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
func (manager *ApiManagerCtx) pullStart(w http.ResponseWriter, r *http.Request) {
@@ -15,25 +15,25 @@ func (manager *ApiManagerCtx) pullStart(w http.ResponseWriter, r *http.Request)
return
}
err := manager.rooms.PullStart(request)
err := manager.pull.Start(request)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
response := manager.rooms.PullStatus()
response := manager.pull.Status()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (manager *ApiManagerCtx) pullStatus(w http.ResponseWriter, r *http.Request) {
response := manager.rooms.PullStatus()
response := manager.pull.Status()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (manager *ApiManagerCtx) pullStop(w http.ResponseWriter, r *http.Request) {
err := manager.rooms.PullStop()
err := manager.pull.Stop()
if err != nil {
http.Error(w, err.Error(), 500)
return
+13 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/go-chi/chi"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
func (manager *ApiManagerCtx) roomsList(w http.ResponseWriter, r *http.Request) {
@@ -150,3 +150,15 @@ func (manager *ApiManagerCtx) roomGenericAction(action func(id string) error) fu
w.WriteHeader(http.StatusNoContent)
}
}
func (manager *ApiManagerCtx) roomSnapshot(w http.ResponseWriter, r *http.Request) {
roomId := chi.URLParam(r, "roomId")
response, err := manager.rooms.Snapshot(roomId)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
-23
View File
@@ -1,23 +0,0 @@
package config
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type API struct {
Lobby bool
}
func (API) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().Bool("lobby", true, "show lobby when room is not started yet; in order to show lobby, neko-rooms must run on the same subdomain where rooms are published")
if err := viper.BindPFlag("lobby", cmd.PersistentFlags().Lookup("lobby")); err != nil {
return err
}
return nil
}
func (s *API) Set() {
s.Lobby = viper.GetBool("lobby")
}
+140 -23
View File
@@ -1,6 +1,8 @@
package config
import (
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
@@ -11,12 +13,25 @@ import (
"github.com/spf13/viper"
)
type Traefik struct {
Enabled bool
Domain string
Entrypoint string
Certresolver string
Port string // deprecated
}
type Room struct {
Mux bool
EprMin uint16
EprMax uint16
NAT1To1IPs []string
NekoImages []string
NAT1To1IPs []string
NekoImages []string
NekoPrivilegedImages []string
PathPrefix string
Labels []string
WaitEnabled bool
StorageEnabled bool
StorageInternal string
@@ -24,17 +39,19 @@ type Room struct {
MountsWhitelist []string
InstanceName string
InstanceUrl string
InstanceName string
InstanceUrl *url.URL
InstanceNetwork string
TraefikDomain string
TraefikEntrypoint string
TraefikCertresolver string
TraefikNetwork string
TraefikPort string // deprecated
Traefik Traefik
}
func (Room) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().StringSlice("usemux", []string{}, "use mux for connection - needs only one UDP and TCP port per room")
if err := viper.BindPFlag("usemux", cmd.PersistentFlags().Lookup("usemux")); err != nil {
return err
}
cmd.PersistentFlags().String("epr", "59000-59999", "limits the pool of ephemeral ports that ICE UDP connections can allocate from")
if err := viper.BindPFlag("epr", cmd.PersistentFlags().Lookup("epr")); err != nil {
return err
@@ -52,15 +69,36 @@ func (Room) Init(cmd *cobra.Command) error {
"m1k1o/neko:ungoogled-chromium",
"m1k1o/neko:microsoft-edge",
"m1k1o/neko:brave",
"m1k1o/neko:vivaldi",
"m1k1o/neko:tor-browser",
"m1k1o/neko:vlc",
"m1k1o/neko:vncviewer",
"m1k1o/neko:remmina",
"m1k1o/neko:xfce",
}, "neko images to be used")
if err := viper.BindPFlag("neko_images", cmd.PersistentFlags().Lookup("neko_images")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("neko_privileged_images", []string{}, "Whitelist of images allowed to be executed with Privileged mode")
if err := viper.BindPFlag("neko_privileged_images", cmd.PersistentFlags().Lookup("neko_privileged_images")); err != nil {
return err
}
cmd.PersistentFlags().String("path_prefix", "/", "path prefix that is added to every room path")
if err := viper.BindPFlag("path_prefix", cmd.PersistentFlags().Lookup("path_prefix")); err != nil {
return err
}
cmd.PersistentFlags().StringSlice("labels", []string{}, "additional labels appended to every room")
if err := viper.BindPFlag("labels", cmd.PersistentFlags().Lookup("labels")); err != nil {
return err
}
cmd.PersistentFlags().Bool("wait_enabled", true, "enable active waiting for the room")
if err := viper.BindPFlag("wait_enabled", cmd.PersistentFlags().Lookup("wait_enabled")); err != nil {
return err
}
// Data
cmd.PersistentFlags().Bool("storage.enabled", true, "whether storage is enabled, where peristent containers data will be stored")
@@ -95,8 +133,18 @@ func (Room) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().String("instance.network", "", "docker network that will be used for this instance to communicate with rooms")
if err := viper.BindPFlag("instance.network", cmd.PersistentFlags().Lookup("instance.network")); err != nil {
return err
}
// Traefik
cmd.PersistentFlags().Bool("traefik.enabled", true, "traefik: enabled or disabled")
if err := viper.BindPFlag("traefik.enabled", cmd.PersistentFlags().Lookup("traefik.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("traefik.domain", "", "traefik: domain on which will be container hosted (if empty or '*', match all; for neko-rooms as subdomain use '*.domain.tld')")
if err := viper.BindPFlag("traefik.domain", cmd.PersistentFlags().Lookup("traefik.domain")); err != nil {
return err
@@ -112,7 +160,7 @@ func (Room) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().String("traefik.network", "traefik", "traefik: docker network name")
cmd.PersistentFlags().String("traefik.network", "traefik", "traefik: docker network name (deprecated, use instance.network)")
if err := viper.BindPFlag("traefik.network", cmd.PersistentFlags().Lookup("traefik.network")); err != nil {
return err
}
@@ -126,6 +174,8 @@ func (Room) Init(cmd *cobra.Command) error {
}
func (s *Room) Set() {
s.Mux = viper.GetBool("mux")
min := uint16(59000)
max := uint16(59999)
epr := viper.GetString("epr")
@@ -152,6 +202,10 @@ func (s *Room) Set() {
s.NAT1To1IPs = viper.GetStringSlice("nat1to1")
s.NekoImages = viper.GetStringSlice("neko_images")
s.NekoPrivilegedImages = viper.GetStringSlice("neko_privileged_images")
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
s.Labels = viper.GetStringSlice("labels")
s.WaitEnabled = viper.GetBool("wait_enabled")
s.StorageEnabled = viper.GetBool("storage.enabled")
s.StorageInternal = viper.GetString("storage.internal")
@@ -183,20 +237,83 @@ func (s *Room) Set() {
log.Panic().Msg("invalid `instance.name`, must match " + dockerNames.RestrictedNameChars)
}
s.InstanceUrl = viper.GetString("instance.url")
instanceUrl := viper.GetString("instance.url")
if instanceUrl != "" {
var err error
s.InstanceUrl, err = url.Parse(instanceUrl)
if err != nil {
log.Panic().Err(err).Msg("invalid `instance.url`")
}
}
s.TraefikDomain = viper.GetString("traefik.domain")
s.TraefikEntrypoint = viper.GetString("traefik.entrypoint")
s.TraefikCertresolver = viper.GetString("traefik.certresolver")
s.TraefikNetwork = viper.GetString("traefik.network")
s.InstanceNetwork = viper.GetString("instance.network")
// deprecated
s.TraefikPort = viper.GetString("traefik.port")
if s.TraefikPort != "" {
if s.InstanceUrl != "" {
log.Warn().Msg("deprecated `traefik.port` config item is ignored when `instance.url` is set")
} else {
log.Warn().Msg("you are using deprecated `traefik.port` config item, you should consider moving to `instance.url`")
s.Traefik.Enabled = viper.GetBool("traefik.enabled")
if s.Traefik.Enabled {
s.Traefik.Domain = viper.GetString("traefik.domain")
s.Traefik.Entrypoint = viper.GetString("traefik.entrypoint")
s.Traefik.Certresolver = viper.GetString("traefik.certresolver")
// deprecated
traefikNetwork := viper.GetString("traefik.network")
if traefikNetwork != "" {
if s.InstanceNetwork != "" {
log.Warn().Msg("deprecated `traefik.network` config item is ignored when `instance.network` is set")
} else {
log.Warn().Msg("you are using deprecated `traefik.network` config item, you should consider moving to `instance.network`")
s.InstanceNetwork = traefikNetwork
}
}
// deprecated
s.Traefik.Port = viper.GetString("traefik.port")
if s.Traefik.Port != "" {
if s.InstanceUrl != nil {
log.Warn().Msg("deprecated `traefik.port` config item is ignored when `instance.url` is set")
} else {
log.Warn().Msg("you are using deprecated `traefik.port` config item, you should consider moving to `instance.url`")
}
}
}
}
func (s *Room) GetInstanceUrl() url.URL {
if s.InstanceUrl != nil {
return *s.InstanceUrl
}
instanceUrl := url.URL{
Scheme: "http",
Host: "127.0.0.1",
Path: "/",
}
if s.Traefik.Enabled {
if s.Traefik.Certresolver != "" {
instanceUrl.Scheme = "https"
}
if s.Traefik.Domain != "" && s.Traefik.Domain != "*" {
instanceUrl.Host = s.Traefik.Domain
}
// deprecated
if s.Traefik.Port != "" {
instanceUrl.Host += ":" + s.Traefik.Port
}
}
return instanceUrl
}
func (s *Room) GetRoomUrl(roomName string) string {
instanceUrl := s.GetInstanceUrl()
if strings.HasPrefix(instanceUrl.Host, "*.") {
instanceUrl.Host = roomName + "." + strings.TrimPrefix(instanceUrl.Host, "*.")
} else {
instanceUrl.Path = path.Join(instanceUrl.Path, s.PathPrefix, roomName) + "/"
}
return instanceUrl.String()
}
+50 -7
View File
@@ -5,11 +5,21 @@ import (
"github.com/spf13/viper"
)
type Admin struct {
Static string
ProxyAuth string
Username string
Password string
}
type Server struct {
Cert string
Key string
Bind string
Static string
Cert string
Key string
Bind string
Proxy bool
PProf bool
Admin Admin
}
func (Server) Init(cmd *cobra.Command) error {
@@ -28,8 +38,35 @@ func (Server) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().String("static", "./www", "path to neko_rooms client files to serve")
if err := viper.BindPFlag("static", cmd.PersistentFlags().Lookup("static")); err != nil {
cmd.PersistentFlags().Bool("proxy", false, "trust reverse proxy headers")
if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil {
return err
}
cmd.PersistentFlags().Bool("pprof", false, "enable pprof endpoint available at /debug/pprof")
if err := viper.BindPFlag("pprof", cmd.PersistentFlags().Lookup("pprof")); err != nil {
return err
}
// Admin
cmd.PersistentFlags().String("admin.static", "", "path to neko_rooms admin client files to serve")
if err := viper.BindPFlag("admin.static", cmd.PersistentFlags().Lookup("admin.static")); err != nil {
return err
}
cmd.PersistentFlags().String("admin.proxy_auth", "", "require auth: proxy authentication URL, only allow if it returns 200")
if err := viper.BindPFlag("admin.proxy_auth", cmd.PersistentFlags().Lookup("admin.proxy_auth")); err != nil {
return err
}
cmd.PersistentFlags().String("admin.username", "admin", "require auth: admin username")
if err := viper.BindPFlag("admin.username", cmd.PersistentFlags().Lookup("admin.username")); err != nil {
return err
}
cmd.PersistentFlags().String("admin.password", "", "require auth: admin password")
if err := viper.BindPFlag("admin.password", cmd.PersistentFlags().Lookup("admin.password")); err != nil {
return err
}
@@ -40,5 +77,11 @@ func (s *Server) Set() {
s.Cert = viper.GetString("cert")
s.Key = viper.GetString("key")
s.Bind = viper.GetString("bind")
s.Static = viper.GetString("static")
s.Proxy = viper.GetBool("proxy")
s.PProf = viper.GetBool("pprof")
s.Admin.Static = viper.GetString("admin.static")
s.Admin.ProxyAuth = viper.GetString("admin.proxy_auth")
s.Admin.Username = viper.GetString("admin.username")
s.Admin.Password = viper.GetString("admin.password")
}
-80
View File
@@ -1,80 +0,0 @@
package http
import (
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"github.com/rs/zerolog/log"
)
func Logger(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
req := map[string]interface{}{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
req["id"] = reqID
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
req["scheme"] = scheme
req["proto"] = r.Proto
req["method"] = r.Method
req["remote"] = r.RemoteAddr
req["agent"] = r.UserAgent()
req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
fields := map[string]interface{}{}
fields["req"] = req
entry := &entry{
fields: fields,
}
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
t1 := time.Now()
defer func() {
entry.Write(ww.Status(), ww.BytesWritten(), time.Since(t1))
}()
next.ServeHTTP(ww, r)
}
return http.HandlerFunc(fn)
}
type entry struct {
fields map[string]interface{}
errors []map[string]interface{}
}
func (e *entry) Write(status, bytes int, elapsed time.Duration) {
res := map[string]interface{}{}
res["time"] = time.Now().UTC().Format(time.RFC1123)
res["status"] = status
res["bytes"] = bytes
res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0
e.fields["res"] = res
e.fields["module"] = "http"
if len(e.errors) > 0 {
e.fields["errors"] = e.errors
log.Error().Fields(e.fields).Msgf("request failed (%d)", status)
} else {
log.Debug().Fields(e.fields).Msgf("request complete (%d)", status)
}
}
func (e *entry) Panic(v interface{}, stack []byte) {
err := map[string]interface{}{}
err["message"] = fmt.Sprintf("%+v", v)
err["stack"] = string(stack)
e.errors = append(e.errors, err)
}
-95
View File
@@ -1,95 +0,0 @@
package http
import (
"context"
"net/http"
"os"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko_rooms/internal/config"
"m1k1o/neko_rooms/internal/types"
)
type HttpManagerCtx struct {
logger zerolog.Logger
router *chi.Mux
http *http.Server
conf *config.Server
}
func New(ApiManager types.ApiManager, conf *config.Server) *HttpManagerCtx {
logger := log.With().Str("module", "http").Logger()
router := chi.NewRouter()
router.Use(middleware.Recoverer) // Recover from panics without crashing server
router.Use(middleware.RequestID) // Create a request ID for each request
router.Use(Logger) // Log API request calls using custom logger function
// Basic CORS
// for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing
router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
router.Route("/api", ApiManager.Mount)
if conf.Static != "" {
fs := http.FileServer(http.Dir(conf.Static))
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
if _, err := os.Stat(conf.Static + r.URL.Path); !os.IsNotExist(err) {
fs.ServeHTTP(w, r)
} else {
http.NotFound(w, r)
}
})
}
// add simple lobby room
router.Get("/{roomName}", ApiManager.RoomLobby)
router.Get("/{roomName}/", ApiManager.RoomLobby)
router.NotFound(http.HandlerFunc(http.NotFound))
http := &http.Server{
Addr: conf.Bind,
Handler: router,
}
return &HttpManagerCtx{
logger: logger,
router: router,
http: http,
conf: conf,
}
}
func (s *HttpManagerCtx) Start() {
if s.conf.Cert != "" && s.conf.Key != "" {
go func() {
if err := s.http.ListenAndServeTLS(s.conf.Cert, s.conf.Key); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start https server")
}
}()
s.logger.Info().Msgf("https listening on %s", s.http.Addr)
} else {
go func() {
if err := s.http.ListenAndServe(); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start http server")
}
}()
s.logger.Info().Msgf("http listening on %s", s.http.Addr)
}
}
func (s *HttpManagerCtx) Shutdown() error {
return s.http.Shutdown(context.Background())
}
+7 -6
View File
@@ -6,7 +6,8 @@ import (
_ "embed"
"encoding/json"
"fmt"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
//go:embed policies.json
@@ -35,17 +36,17 @@ func Generate(policies types.BrowserPolicyContent) (string, error) {
)
}
ExtensionInstallWhitelist := []interface{}{}
ExtensionInstallAllowlist := []interface{}{}
for _, e := range policies.Extensions {
ExtensionInstallWhitelist = append(
ExtensionInstallWhitelist,
ExtensionInstallAllowlist = append(
ExtensionInstallAllowlist,
e.ID,
)
}
policiesTmpl["ExtensionInstallForcelist"] = ExtensionInstallForcelist
policiesTmpl["ExtensionInstallWhitelist"] = ExtensionInstallWhitelist
policiesTmpl["ExtensionInstallBlacklist"] = []interface{}{"*"}
policiesTmpl["ExtensionInstallAllowlist"] = ExtensionInstallAllowlist
policiesTmpl["ExtensionInstallBlocklist"] = []interface{}{"*"}
//
// Developer Tools
+2 -1
View File
@@ -4,8 +4,9 @@ package chromium
import (
"encoding/json"
"m1k1o/neko_rooms/internal/types"
"strings"
"github.com/m1k1o/neko-rooms/internal/types"
)
func Parse(policiesJson string) (*types.BrowserPolicyContent, error) {
+3 -5
View File
@@ -1,6 +1,4 @@
{
"HomepageLocation": "",
"AutoFillEnabled": false,
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"BrowserSignin": 0,
@@ -20,7 +18,7 @@
"PromptForDownloadLocation": false,
"BookmarkBarEnabled": false,
"PasswordManagerEnabled": false,
"URLBlacklist": [
"URLBlocklist": [
"file://*",
"chrome://policy"
],
@@ -28,11 +26,11 @@
"cjpalhdlnbpafiamejdnhcphjbkeiagm;https://clients2.google.com/service/update2/crx",
"fjoaledfpmneenckfbpdfhkmimnjocfa;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallWhitelist": [
"ExtensionInstallAllowlist": [
"cjpalhdlnbpafiamejdnhcphjbkeiagm",
"fjoaledfpmneenckfbpdfhkmimnjocfa"
],
"ExtensionInstallBlacklist": [
"ExtensionInstallBlocklist": [
"*"
]
}
+4 -3
View File
@@ -2,9 +2,10 @@ package policies
import (
"errors"
"m1k1o/neko_rooms/internal/policies/chromium"
"m1k1o/neko_rooms/internal/policies/firefox"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/policies/chromium"
"github.com/m1k1o/neko-rooms/internal/policies/firefox"
"github.com/m1k1o/neko-rooms/internal/types"
)
func Generate(policies types.BrowserPolicyContent, policyType types.BrowserPolicyType) (string, error) {
+2 -1
View File
@@ -5,7 +5,8 @@ package firefox
import (
_ "embed"
"encoding/json"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
//go:embed policies.json
+2 -1
View File
@@ -5,7 +5,8 @@ package firefox
import (
_ "embed"
"encoding/json"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
func Parse(policiesJson string) (*types.BrowserPolicyContent, error) {
@@ -1,41 +1,46 @@
package api
package proxy
import (
"net/http"
"strings"
"github.com/go-chi/chi"
"m1k1o/neko_rooms/internal/utils"
"github.com/m1k1o/neko-rooms/internal/utils"
)
func (manager *ApiManagerCtx) RoomLobby(w http.ResponseWriter, r *http.Request) {
if !manager.conf.Lobby {
http.NotFound(w, r)
}
func roomWait(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<script>
(async function() {
document.querySelector(".swal2-loader").style.display = 'block'
document.querySelector(".swal2-loader").style.visibility = 'hidden'
roomName := chi.URLParam(r, "roomName")
response, err := manager.rooms.FindByName(roomName)
let lastAttempt = (new Date()).getTime()
while(true) {
try {
lastAttempt = (new Date()).getTime()
document.querySelector(".swal2-loader").style.visibility = 'visible'
if err != nil || response.Name != roomName {
manager.roomNotFound(w, r)
return
}
await fetch("?wait")
location.href = location.href
} catch {
let now = (new Date()).getTime()
let diff = now - lastAttempt
if !response.Running {
manager.roomNotRunning(w, r)
return
}
// if the gap between last attempt and now
// is gt 20s, do reconnect immediatly
if ((now - lastAttempt) > 20*1000) {
continue
}
if strings.Contains(response.Status, "starting") {
manager.roomNotReady(w, r)
return
}
manager.roomReady(w, r)
// wait for 10 sec
await new Promise(res => setTimeout(res, 2500))
document.querySelector(".swal2-loader").style.visibility = 'hidden'
await new Promise(res => setTimeout(res, 7500))
}
}
}())
</script>`))
}
func (manager *ApiManagerCtx) roomNotFound(w http.ResponseWriter, r *http.Request) {
func RoomNotFound(w http.ResponseWriter, r *http.Request, waitEnabled bool) {
utils.Swal2Response(w, `
<div class="swal2-header">
<div class="swal2-icon swal2-error">
@@ -45,11 +50,19 @@ func (manager *ApiManagerCtx) roomNotFound(w http.ResponseWriter, r *http.Reques
</div>
<div class="swal2-content">
<div>The room you are trying to join does not exist.</div>
<div>You can wait on this page until it will be created.</div>
</div>
<div class="swal2-actions">
<div class="swal2-loader" style="display:none;"></div>
</div>
`)
if waitEnabled {
roomWait(w, r)
}
}
func (manager *ApiManagerCtx) roomNotRunning(w http.ResponseWriter, r *http.Request) {
func RoomNotRunning(w http.ResponseWriter, r *http.Request, waitEnabled bool) {
utils.Swal2Response(w, `
<div class="swal2-header">
<div class="swal2-icon swal2-warning">
@@ -59,11 +72,19 @@ func (manager *ApiManagerCtx) roomNotRunning(w http.ResponseWriter, r *http.Requ
</div>
<div class="swal2-content">
<div>The room you are trying to join is not running.</div>
<div>You can wait on this page until it will be started.</div>
</div>
<div class="swal2-actions">
<div class="swal2-loader" style="display:none;"></div>
</div>
`)
if waitEnabled {
roomWait(w, r)
}
}
func (manager *ApiManagerCtx) roomNotReady(w http.ResponseWriter, r *http.Request) {
func RoomNotReady(w http.ResponseWriter, r *http.Request) {
utils.Swal2Response(w, `
<meta http-equiv="refresh" content="2">
@@ -83,7 +104,7 @@ func (manager *ApiManagerCtx) roomNotReady(w http.ResponseWriter, r *http.Reques
`)
}
func (manager *ApiManagerCtx) roomReady(w http.ResponseWriter, r *http.Request) {
func RoomReady(w http.ResponseWriter, r *http.Request) {
utils.Swal2Response(w, `
<div class="swal2-header">
<div class="swal2-icon swal2-success swal2-icon-show" style="display: flex;">
+361
View File
@@ -0,0 +1,361 @@
package proxy
import (
"context"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strconv"
"strings"
"sync"
dockerTypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type entry struct {
id string
running bool
handler *httputil.ReverseProxy
}
type wait struct {
subs int
signal chan struct{}
}
type ProxyManagerCtx struct {
logger zerolog.Logger
mu sync.RWMutex
ctx context.Context
cancel func()
waitMu sync.RWMutex
waitChans map[string]*wait
waitEnabled bool
client *dockerClient.Client
instanceName string
handlers *prefixHandler[*entry]
}
func New(client *dockerClient.Client, instanceName string, waitEnabled bool) *ProxyManagerCtx {
return &ProxyManagerCtx{
logger: log.With().Str("module", "proxy").Logger(),
waitChans: map[string]*wait{},
client: client,
instanceName: instanceName,
waitEnabled: waitEnabled,
handlers: &prefixHandler[*entry]{},
}
}
func (p *ProxyManagerCtx) Start() {
p.ctx, p.cancel = context.WithCancel(context.Background())
go func() {
err := p.Refresh()
if err != nil {
p.logger.Err(err).Msg("unable to refresh containers")
}
msgs, errs := p.client.Events(p.ctx, dockerTypes.EventsOptions{
Filters: filters.NewArgs(
filters.Arg("type", "container"),
filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", p.instanceName)),
filters.Arg("event", "create"),
filters.Arg("event", "start"),
//filters.Arg("event", "health_status"),
filters.Arg("event", "stop"),
filters.Arg("event", "destroy"),
),
})
for {
select {
case err := <-errs:
p.logger.Err(err).Msg("got docker event")
case msg := <-msgs:
enabled, path, port, ok := p.parseLabels(msg.Actor.Attributes)
if !ok {
break
}
host := msg.ID[:12] + ":" + port
p.logger.Info().
Str("action", msg.Action).
Str("path", path).
Str("host", host).
Msg("got docker event")
// terminate waiting for any events
if p.waitEnabled {
p.waitMu.Lock()
ch, ok := p.waitChans[path]
if ok {
close(ch.signal)
delete(p.waitChans, path)
}
p.waitMu.Unlock()
}
p.mu.Lock()
switch msg.Action {
case "create":
p.handlers.Set(path, &entry{
id: msg.ID,
running: false,
})
case "start":
e := &entry{
id: msg.ID,
running: true,
}
// if proxying is disabled
if enabled {
e.handler = httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: host,
})
}
p.handlers.Set(path, e)
case "stop":
p.handlers.Set(path, &entry{
id: msg.ID,
running: false,
})
case "destroy":
p.handlers.Remove(path)
}
p.mu.Unlock()
}
}
}()
}
func (p *ProxyManagerCtx) Shutdown() error {
p.cancel()
return p.ctx.Err()
}
func (p *ProxyManagerCtx) Refresh() error {
p.mu.Lock()
defer p.mu.Unlock()
containers, err := p.client.ContainerList(p.ctx, dockerTypes.ContainerListOptions{
All: true,
Filters: filters.NewArgs(
filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", p.instanceName)),
),
})
if err != nil {
return err
}
p.handlers = &prefixHandler[*entry]{}
for _, cont := range containers {
enabled, path, port, ok := p.parseLabels(cont.Labels)
if !ok {
continue
}
host := cont.ID[:12] + ":" + port
entry := &entry{
id: cont.ID,
}
if cont.State == "running" {
entry.running = true
// if proxying is disabled
if enabled {
entry.handler = httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: host,
})
}
}
p.handlers.Set(path, entry)
}
return nil
}
func (p *ProxyManagerCtx) parseLabels(labels map[string]string) (enabled bool, path string, port string, ok bool) {
enabledStr, found := labels["m1k1o.neko_rooms.proxy.enabled"]
if !found {
//
// workaround for legacy traefik labels
//
// get room name
var roomName string
roomName, ok = labels["m1k1o.neko_rooms.name"]
if !ok {
return
}
// get container name
containerName := p.instanceName + "-" + roomName
// get path
path, ok = labels["traefik.http.middlewares."+containerName+"-prf.stripprefix.prefixes"]
if !ok {
return
}
// remove last /
path = strings.TrimSuffix(path, "/")
// get port
port, ok = labels["traefik.http.services."+containerName+"-frontend.loadbalancer.server.port"]
if !ok {
return
}
//
// workaround for legacy traefik labels
//
return
}
enabledBool, err := strconv.ParseBool(enabledStr)
enabled = enabledBool && err == nil
path, ok = labels["m1k1o.neko_rooms.proxy.path"]
if !ok {
return
}
port, ok = labels["m1k1o.neko_rooms.proxy.port"]
return
}
func (p *ProxyManagerCtx) waitForPath(w http.ResponseWriter, r *http.Request, path string) {
p.logger.Debug().Str("path", path).Msg("adding new wait handler")
p.waitMu.Lock()
ch, ok := p.waitChans[path]
if !ok {
ch = &wait{
subs: 1,
signal: make(chan struct{}),
}
p.waitChans[path] = ch
} else {
p.waitChans[path].subs += 1
}
p.waitMu.Unlock()
select {
case <-ch.signal:
w.Write([]byte("ready"))
case <-r.Context().Done():
http.Error(w, r.Context().Err().Error(), http.StatusRequestTimeout)
p.waitMu.Lock()
ch.subs -= 1
if ch.subs <= 0 {
delete(p.waitChans, path)
}
p.waitMu.Unlock()
p.logger.Debug().Str("path", path).Msg("wait handler removed")
case <-p.ctx.Done():
w.Write([]byte("shutdown"))
}
}
func (p *ProxyManagerCtx) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cleanPath := path.Clean(r.URL.Path)
// get proxy by room name
p.mu.RLock()
proxy, prefix, ok := p.handlers.Match(cleanPath)
p.mu.RUnlock()
// if room not found
if !ok {
// blocking until room is created
if r.URL.Query().Has("wait") && p.waitEnabled {
p.waitForPath(w, r, cleanPath)
return
}
RoomNotFound(w, r, p.waitEnabled)
return
}
// redirect to room ending with /
if cleanPath == prefix && !strings.HasSuffix(r.URL.Path, "/") {
r.URL.Path = cleanPath + "/"
http.Redirect(w, r, r.URL.String(), http.StatusTemporaryRedirect)
return
}
// if room not running
if !proxy.running {
// blocking until room is running
if r.URL.Query().Has("wait") && p.waitEnabled {
p.waitForPath(w, r, cleanPath)
return
}
RoomNotRunning(w, r, p.waitEnabled)
return
}
// if not proxying, check for container readiness status
if proxy.handler == nil {
containers, err := p.client.ContainerList(p.ctx, dockerTypes.ContainerListOptions{
All: true,
Filters: filters.NewArgs(
filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", p.instanceName)),
filters.Arg("id", proxy.id),
),
})
if err != nil || len(containers) != 1 {
p.logger.Err(err).Msg("error while getting container ready status")
RoomNotReady(w, r)
return
}
container := containers[0]
if strings.Contains(container.Status, "starting") {
RoomNotReady(w, r)
} else {
RoomReady(w, r)
}
return
}
// handle not ready room
proxy.handler.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
if r.URL.Path == "/" {
RoomNotReady(w, r)
}
p.logger.Err(err).Str("prefix", prefix).Msg("proxying error")
}
// strip prefix from proxy
handler := http.StripPrefix(prefix, proxy.handler)
// handle by proxy
handler.ServeHTTP(w, r)
}
+126
View File
@@ -0,0 +1,126 @@
package proxy
import (
"strings"
)
type prefixHandler[T any] struct {
Value T
IsLeaf bool
Children map[string]*prefixHandler[T]
}
func NewPrefixHandler[T any]() *prefixHandler[T] {
return &prefixHandler[T]{}
}
func (p *prefixHandler[T]) Set(prefix string, value T) {
arr := strings.Split(prefix, "/")
len := len(arr)
for i, a := range arr {
if a == "" {
continue
}
if p.Children == nil {
p.Children = map[string]*prefixHandler[T]{}
}
dat, ok := p.Children[a]
if !ok {
dat = &prefixHandler[T]{}
}
if i == len-1 {
dat.Value = value
dat.IsLeaf = true
dat.Children = nil
}
p.Children[a] = dat
p = dat
}
}
func (p *prefixHandler[T]) Get(prefix string) (value T, ok bool) {
arr := strings.Split(prefix, "/")
len := len(arr)
for i, a := range arr {
if a == "" {
continue
}
if p.Children == nil {
p.Children = map[string]*prefixHandler[T]{}
}
dat, found := p.Children[a]
if !found {
dat = &prefixHandler[T]{}
}
if i == len-1 {
value = dat.Value
ok = dat.IsLeaf
return
}
p.Children[a] = dat
p = dat
}
return
}
func (p *prefixHandler[T]) Remove(prefix string) {
arr := strings.Split(prefix, "/")
len := len(arr)
for i, a := range arr {
if a == "" {
continue
}
if i == len-1 {
delete(p.Children, a)
}
dat, found := p.Children[a]
if !found {
break
}
p = dat
}
// TODO: Remove all empty references.
}
func (p *prefixHandler[T]) Match(path string) (value T, prefix string, ok bool) {
arr := strings.Split(path, "/")
prefixArr := []string{}
for _, a := range arr {
if a == "" {
continue
}
pointer, found := p.Children[a]
if !found {
return
}
p = pointer
prefixArr = append(prefixArr, a)
if p.IsLeaf {
break
}
}
value = p.Value
prefix = "/" + strings.Join(prefixArr, "/")
ok = p.IsLeaf
return
}
+170
View File
@@ -0,0 +1,170 @@
package pull
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"sync"
"time"
dockerTypes "github.com/docker/docker/api/types"
dockerClient "github.com/docker/docker/client"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko-rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/utils"
)
type PullManagerCtx struct {
logger zerolog.Logger
client *dockerClient.Client
images []string
mu sync.Mutex
cancel func()
status types.PullStatus
layers map[string]int
}
func New(client *dockerClient.Client, nekoImages []string) *PullManagerCtx {
return &PullManagerCtx{
logger: log.With().Str("module", "pull").Logger(),
client: client,
images: nekoImages,
}
}
func (manager *PullManagerCtx) tryInitialize(cancel func()) bool {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.status.Active {
cancel()
return false
}
now := time.Now()
manager.cancel = cancel
manager.status = types.PullStatus{
Active: true,
Started: &now,
Layers: []types.PullLayer{},
Status: []string{},
}
manager.layers = map[string]int{}
return true
}
func (manager *PullManagerCtx) setDone() {
manager.mu.Lock()
defer manager.mu.Unlock()
now := time.Now()
manager.status.Active = false
manager.status.Finished = &now
}
func (manager *PullManagerCtx) Start(request types.PullStart) error {
if in, _ := utils.ArrayIn(request.NekoImage, manager.images); !in {
return fmt.Errorf("unknown neko image")
}
ctx, cancel := context.WithCancel(context.Background())
if !manager.tryInitialize(cancel) {
return fmt.Errorf("pull is already in progess")
}
// handle registry auth
var opts dockerTypes.ImagePullOptions
if request.RegistryUser != "" && request.RegistryPass != "" {
authConfig := dockerTypes.AuthConfig{
Username: request.RegistryUser,
Password: request.RegistryPass,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return err
}
opts = dockerTypes.ImagePullOptions{
RegistryAuth: base64.URLEncoding.EncodeToString(encodedJSON),
}
}
reader, err := manager.client.ImagePull(ctx, request.NekoImage, opts)
if err != nil {
manager.setDone()
return err
}
go func() {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
data := scanner.Bytes()
layer := types.PullLayer{}
if err := json.Unmarshal(data, &layer); err != nil {
manager.status.Status = append(
manager.status.Status,
fmt.Sprintf("Error while parsing pull response: %s", err),
)
continue
}
if layer.ProgressDetail != nil {
// map layer id to slice index
if index, ok := manager.layers[layer.ID]; ok {
manager.status.Layers[index] = layer
} else {
manager.layers[layer.ID] = len(manager.layers)
manager.status.Layers = append(manager.status.Layers, layer)
}
} else {
manager.status.Status = append(
manager.status.Status,
layer.Status,
)
}
}
if err := scanner.Err(); err != nil {
manager.status.Status = append(
manager.status.Status,
fmt.Sprintf("Error while reading pull response: %s", err),
)
}
reader.Close()
manager.setDone()
}()
return nil
}
func (manager *PullManagerCtx) Stop() error {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.status.Active {
return fmt.Errorf("pull is not in progess")
}
manager.cancel()
return nil
}
func (manager *PullManagerCtx) Status() types.PullStatus {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.status
}
+9 -3
View File
@@ -9,7 +9,7 @@ import (
dockerTypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
func (manager *RoomManagerCtx) containerToEntry(container dockerTypes.Container) (*types.RoomEntry, error) {
@@ -18,7 +18,7 @@ func (manager *RoomManagerCtx) containerToEntry(container dockerTypes.Container)
return nil, err
}
return &types.RoomEntry{
entry := &types.RoomEntry{
ID: container.ID,
URL: labels.URL,
Name: labels.Name,
@@ -28,7 +28,13 @@ func (manager *RoomManagerCtx) containerToEntry(container dockerTypes.Container)
Running: container.State == "running",
Status: container.Status,
Created: time.Unix(container.Created, 0),
}, nil
}
if labels.Mux {
entry.MaxConnections = 0
}
return entry, nil
}
func (manager *RoomManagerCtx) listContainers() ([]dockerTypes.Container, error) {
+54 -24
View File
@@ -4,12 +4,13 @@ import (
"fmt"
"strconv"
"m1k1o/neko_rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/types"
)
type RoomLabels struct {
Name string
URL string
Mux bool
Epr EprPorts
NekoImage string
@@ -29,7 +30,9 @@ func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLab
url, ok := labels["m1k1o.neko_rooms.url"]
if !ok {
return nil, fmt.Errorf("damaged container labels: url not found")
// TODO: It should be always available.
url = manager.config.GetRoomUrl(name)
//return nil, fmt.Errorf("damaged container labels: url not found")
}
nekoImage, ok := labels["m1k1o.neko_rooms.neko_image"]
@@ -37,24 +40,47 @@ func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLab
return nil, fmt.Errorf("damaged container labels: neko_image not found")
}
eprMinStr, ok := labels["m1k1o.neko_rooms.epr.min"]
if !ok {
return nil, fmt.Errorf("damaged container labels: epr.min not found")
}
var mux bool
var epr EprPorts
eprMin, err := strconv.ParseUint(eprMinStr, 10, 16)
if err != nil {
return nil, err
}
muxStr, ok := labels["m1k1o.neko_rooms.mux"]
if ok {
muxPort, err := strconv.ParseUint(muxStr, 10, 16)
if err != nil {
return nil, err
}
eprMaxStr, ok := labels["m1k1o.neko_rooms.epr.max"]
if !ok {
return nil, fmt.Errorf("damaged container labels: epr.max not found")
}
mux = true
epr = EprPorts{
Min: uint16(muxPort),
Max: uint16(muxPort),
}
} else {
eprMinStr, ok := labels["m1k1o.neko_rooms.epr.min"]
if !ok {
return nil, fmt.Errorf("damaged container labels: epr.min not found")
}
eprMax, err := strconv.ParseUint(eprMaxStr, 10, 16)
if err != nil {
return nil, err
eprMin, err := strconv.ParseUint(eprMinStr, 10, 16)
if err != nil {
return nil, err
}
eprMaxStr, ok := labels["m1k1o.neko_rooms.epr.max"]
if !ok {
return nil, fmt.Errorf("damaged container labels: epr.max not found")
}
eprMax, err := strconv.ParseUint(eprMaxStr, 10, 16)
if err != nil {
return nil, err
}
mux = false
epr = EprPorts{
Min: uint16(eprMin),
Max: uint16(eprMax),
}
}
var browserPolicy *BrowserPolicyLabels
@@ -79,10 +105,9 @@ func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLab
Name: name,
URL: url,
NekoImage: nekoImage,
Epr: EprPorts{
Min: uint16(eprMin),
Max: uint16(eprMax),
},
Mux: mux,
Epr: epr,
BrowserPolicy: browserPolicy,
}, nil
}
@@ -90,13 +115,18 @@ func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLab
func (manager *RoomManagerCtx) serializeLabels(labels RoomLabels) map[string]string {
labelsMap := map[string]string{
"m1k1o.neko_rooms.name": labels.Name,
"m1k1o.neko_rooms.url": labels.URL,
"m1k1o.neko_rooms.url": manager.config.GetRoomUrl(labels.Name),
"m1k1o.neko_rooms.instance": manager.config.InstanceName,
"m1k1o.neko_rooms.epr.min": fmt.Sprintf("%d", labels.Epr.Min),
"m1k1o.neko_rooms.epr.max": fmt.Sprintf("%d", labels.Epr.Max),
"m1k1o.neko_rooms.neko_image": labels.NekoImage,
}
if labels.Mux && labels.Epr.Min == labels.Epr.Max {
labelsMap["m1k1o.neko_rooms.mux"] = fmt.Sprintf("%d", labels.Epr.Min)
} else {
labelsMap["m1k1o.neko_rooms.epr.min"] = fmt.Sprintf("%d", labels.Epr.Min)
labelsMap["m1k1o.neko_rooms.epr.max"] = fmt.Sprintf("%d", labels.Epr.Max)
}
if labels.BrowserPolicy != nil {
labelsMap["m1k1o.neko_rooms.browser_policy"] = "true"
labelsMap["m1k1o.neko_rooms.browser_policy.type"] = string(labels.BrowserPolicy.Type)
+172 -96
View File
@@ -8,8 +8,10 @@ import (
"path"
"path/filepath"
"strings"
"time"
dockerTypes "github.com/docker/docker/api/types"
containerTypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/container"
dockerMount "github.com/docker/docker/api/types/mount"
network "github.com/docker/docker/api/types/network"
@@ -20,10 +22,10 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"m1k1o/neko_rooms/internal/config"
"m1k1o/neko_rooms/internal/policies"
"m1k1o/neko_rooms/internal/types"
"m1k1o/neko_rooms/internal/utils"
"github.com/m1k1o/neko-rooms/internal/config"
"github.com/m1k1o/neko-rooms/internal/policies"
"github.com/m1k1o/neko-rooms/internal/types"
"github.com/m1k1o/neko-rooms/internal/utils"
)
const (
@@ -34,20 +36,13 @@ const (
privateStorageGid = 1000
)
func New(config *config.Room) *RoomManagerCtx {
func New(client *dockerClient.Client, config *config.Room) *RoomManagerCtx {
logger := log.With().Str("module", "room").Logger()
cli, err := dockerClient.NewClientWithOpts(dockerClient.FromEnv)
if err != nil {
logger.Panic().Err(err).Msg("unable to connect to docker client")
} else {
logger.Info().Msg("successfully connected to docker client")
}
return &RoomManagerCtx{
logger: logger,
config: config,
client: cli,
client: client,
}
}
@@ -55,7 +50,6 @@ type RoomManagerCtx struct {
logger zerolog.Logger
config *config.Room
client *dockerClient.Client
pull PullConfig
}
func (manager *RoomManagerCtx) Config() types.RoomsConfig {
@@ -63,6 +57,7 @@ func (manager *RoomManagerCtx) Config() types.RoomsConfig {
Connections: manager.config.EprMax - manager.config.EprMin + 1,
NekoImages: manager.config.NekoImages,
StorageEnabled: manager.config.StorageEnabled,
UsesMux: manager.config.Mux,
}
}
@@ -94,19 +89,36 @@ func (manager *RoomManagerCtx) FindByName(name string) (*types.RoomEntry, error)
return manager.containerToEntry(*container)
}
func (manager *RoomManagerCtx) IsValidImage(imageName string, imageList []string) bool {
for _, _nekoImage := range imageList {
if _nekoImage == imageName {
return true
}
if strings.Contains(_nekoImage, "*") {
pattern := strings.Replace(_nekoImage, "*", "", -1)
if strings.Contains(imageName, pattern) {
return true
}
}
}
return false
}
func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, error) {
if settings.Name != "" && !dockerNames.RestrictedNamePattern.MatchString(settings.Name) {
return "", fmt.Errorf("invalid container name, must match " + dockerNames.RestrictedNameChars)
return "", fmt.Errorf("invalid container name, must match %s", dockerNames.RestrictedNameChars)
}
if !manager.config.StorageEnabled && len(settings.Mounts) > 0 {
return "", fmt.Errorf("mounts cannot be specified, because storage is disabled or unavailable")
}
if in, _ := utils.ArrayIn(settings.NekoImage, manager.config.NekoImages); !in {
return "", fmt.Errorf("invalid neko image")
if !manager.IsValidImage(settings.NekoImage, manager.config.NekoImages) {
return "", fmt.Errorf("invalid neko image: %q. valid are: %q", settings.NekoImage, manager.config.NekoImages)
}
isPrivilegedImage := manager.IsValidImage(settings.NekoImage, manager.config.NekoPrivilegedImages)
// TODO: Check if path name exists.
roomName := settings.Name
if roomName == "" {
@@ -117,107 +129,54 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
}
}
containerName := manager.config.InstanceName + "-" + roomName
//
// Allocate ports
//
epr, err := manager.allocatePorts(settings.MaxConnections)
portsNeeded := settings.MaxConnections
if manager.config.Mux {
portsNeeded = 1
}
epr, err := manager.allocatePorts(portsNeeded)
if err != nil {
return "", err
}
portBindings := nat.PortMap{}
exposedPorts := nat.PortSet{
nat.Port(fmt.Sprintf("%d/udp", frontendPort)): struct{}{},
}
for port := epr.Min; port <= epr.Max; port++ {
portKey := nat.Port(fmt.Sprintf("%d/udp", port))
portBindings[portKey] = []nat.PortBinding{
portBindings[nat.Port(fmt.Sprintf("%d/udp", port))] = []nat.PortBinding{
{
HostIP: "0.0.0.0",
HostPort: fmt.Sprintf("%d", port),
},
}
exposedPorts[portKey] = struct{}{}
}
//
// Set traefik labels
//
containerName := manager.config.InstanceName + "-" + roomName
// create traefik rule
traefikRule := "PathPrefix(`/" + roomName + "`)"
if manager.config.TraefikDomain != "" && manager.config.TraefikDomain != "*" {
// match *.domain.tld as subdomain
if strings.HasPrefix(manager.config.TraefikDomain, "*.") {
traefikRule = fmt.Sprintf(
"Host(`%s.%s`)",
roomName,
strings.TrimPrefix(manager.config.TraefikDomain, "*."),
)
} else {
traefikRule += " && Host(`" + manager.config.TraefikDomain + "`)"
// expose TCP port as well when using mux
if manager.config.Mux {
portBindings[nat.Port(fmt.Sprintf("%d/tcp", port))] = []nat.PortBinding{
{
HostIP: "0.0.0.0",
HostPort: fmt.Sprintf("%d", port),
},
}
}
} else {
traefikRule += " && HostRegexp(`{host:.+}`)"
}
traefikLabels := map[string]string{
"traefik.enable": "true",
"traefik.http.services." + containerName + "-frontend.loadbalancer.server.port": fmt.Sprintf("%d", frontendPort),
"traefik.http.routers." + containerName + ".entrypoints": manager.config.TraefikEntrypoint,
"traefik.http.routers." + containerName + ".rule": traefikRule,
"traefik.http.middlewares." + containerName + "-rdr.redirectregex.regex": "/" + roomName + "$$",
"traefik.http.middlewares." + containerName + "-rdr.redirectregex.replacement": "/" + roomName + "/",
"traefik.http.middlewares." + containerName + "-prf.stripprefix.prefixes": "/" + roomName + "/",
"traefik.http.routers." + containerName + ".middlewares": containerName + "-rdr," + containerName + "-prf",
exposedPorts := nat.PortSet{
nat.Port(fmt.Sprintf("%d/tcp", frontendPort)): struct{}{},
}
// optional HTTPS
if manager.config.TraefikCertresolver != "" {
traefikLabels["traefik.http.routers."+containerName+".tls"] = "true"
traefikLabels["traefik.http.routers."+containerName+".tls.certresolver"] = manager.config.TraefikCertresolver
for port := range portBindings {
exposedPorts[port] = struct{}{}
}
//
// Set internal labels
//
instanceUrl := manager.config.InstanceUrl
instanceSuffix := roomName + "/"
if instanceUrl == "" {
urlProto := "http"
if manager.config.TraefikCertresolver != "" {
urlProto = "https"
}
// deprecated
port := ""
if manager.config.TraefikPort != "" {
port = ":" + manager.config.TraefikPort
}
if manager.config.TraefikDomain != "" && manager.config.TraefikDomain != "*" {
// match *.domain.tld as subdomain
if strings.HasPrefix(manager.config.TraefikDomain, "*.") {
instanceUrl = urlProto + "://" + roomName + "." + strings.TrimPrefix(manager.config.TraefikDomain, "*.") + port + "/"
instanceSuffix = ""
} else {
instanceUrl = urlProto + "://" + manager.config.TraefikDomain + port + "/"
}
} else {
// domain is not known
instanceUrl = urlProto + "://127.0.0.1" + port + "/"
}
} else if !strings.HasSuffix(instanceUrl, "/") {
instanceUrl = instanceUrl + "/"
}
var browserPolicyLabels *BrowserPolicyLabels
if settings.BrowserPolicy != nil {
browserPolicyLabels = &BrowserPolicyLabels{
@@ -228,15 +187,77 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
labels := manager.serializeLabels(RoomLabels{
Name: roomName,
URL: instanceUrl + instanceSuffix,
Mux: manager.config.Mux,
Epr: epr,
NekoImage: settings.NekoImage,
BrowserPolicy: browserPolicyLabels,
})
for k, v := range traefikLabels {
labels[k] = v
//
// Set traefik labels
//
pathPrefix := path.Join("/", manager.config.PathPrefix, roomName)
if t := manager.config.Traefik; t.Enabled {
// create traefik rule
traefikRule := "PathPrefix(`" + pathPrefix + "`)"
if t.Domain != "" && t.Domain != "*" {
// match *.domain.tld as subdomain
if strings.HasPrefix(t.Domain, "*.") {
traefikRule = fmt.Sprintf(
"Host(`%s.%s`)",
roomName,
strings.TrimPrefix(t.Domain, "*."),
)
} else {
traefikRule += " && Host(`" + t.Domain + "`)"
}
} else {
traefikRule += " && HostRegexp(`{host:.+}`)"
}
labels["traefik.enable"] = "true"
labels["traefik.http.services."+containerName+"-frontend.loadbalancer.server.port"] = fmt.Sprintf("%d", frontendPort)
labels["traefik.http.routers."+containerName+".entrypoints"] = t.Entrypoint
labels["traefik.http.routers."+containerName+".rule"] = traefikRule
labels["traefik.http.middlewares."+containerName+"-rdr.redirectregex.regex"] = pathPrefix + "$$"
labels["traefik.http.middlewares."+containerName+"-rdr.redirectregex.replacement"] = pathPrefix + "/"
labels["traefik.http.middlewares."+containerName+"-prf.stripprefix.prefixes"] = pathPrefix + "/"
labels["traefik.http.routers."+containerName+".middlewares"] = containerName + "-rdr," + containerName + "-prf"
labels["traefik.http.routers."+containerName+".service"] = containerName + "-frontend"
// optional HTTPS
if t.Certresolver != "" {
labels["traefik.http.routers."+containerName+".tls"] = "true"
labels["traefik.http.routers."+containerName+".tls.certresolver"] = t.Certresolver
}
} else {
labels["m1k1o.neko_rooms.proxy.enabled"] = "true"
labels["m1k1o.neko_rooms.proxy.path"] = pathPrefix
labels["m1k1o.neko_rooms.proxy.port"] = fmt.Sprintf("%d", frontendPort)
}
// add custom labels
for _, label := range manager.config.Labels {
// replace dynamic values in labels
label = strings.Replace(label, "{containerName}", containerName, -1)
label = strings.Replace(label, "{roomName}", roomName, -1)
if t := manager.config.Traefik; t.Enabled {
label = strings.Replace(label, "{traefikEntrypoint}", t.Entrypoint, -1)
label = strings.Replace(label, "{traefikCertresolver}", t.Certresolver, -1)
}
v := strings.SplitN(label, "=", 2)
if len(v) != 2 {
manager.logger.Warn().Str("label", label).Msg("invalid custom label")
continue
}
key, val := v[0], v[1]
labels[key] = val
}
//
@@ -245,10 +266,20 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
env := []string{
fmt.Sprintf("NEKO_BIND=:%d", frontendPort),
fmt.Sprintf("NEKO_EPR=%d-%d", epr.Min, epr.Max),
"NEKO_ICELITE=true",
}
if manager.config.Mux {
env = append(env,
fmt.Sprintf("NEKO_UDPMUX=%d", epr.Min),
fmt.Sprintf("NEKO_TCPMUX=%d", epr.Min),
)
} else {
env = append(env,
fmt.Sprintf("NEKO_EPR=%d-%d", epr.Min, epr.Max),
)
}
// optional nat mapping
if len(manager.config.NAT1To1IPs) > 0 {
env = append(env, fmt.Sprintf("NEKO_NAT1TO1=%s", strings.Join(manager.config.NAT1To1IPs, ",")))
@@ -420,11 +451,13 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
NanoCPUs: settings.Resources.NanoCPUs,
Memory: settings.Resources.Memory,
},
// Privileged
Privileged: isPrivilegedImage,
}
networkingConfig := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
manager.config.TraefikNetwork: {},
manager.config.InstanceNetwork: {},
},
}
@@ -557,6 +590,10 @@ func (manager *RoomManagerCtx) GetSettings(id string) (*types.RoomSettings, erro
Resources: roomResources,
}
if labels.Mux {
settings.MaxConnections = 0
}
err = settings.FromEnv(container.Config.Env)
return &settings, err
}
@@ -617,3 +654,42 @@ func (manager *RoomManagerCtx) Restart(id string) error {
// Restart the actual container
return manager.client.ContainerRestart(context.Background(), id, nil)
}
func (manager *RoomManagerCtx) Snapshot(id string) (*types.RoomSnapshot, error) {
container, err := manager.inspectContainer(id)
if err != nil {
return nil, err
}
imageListOps := dockerTypes.ImageListOptions{
All: true,
}
images, _ := manager.client.ImageList(context.Background(), imageListOps)
var containerImage string
for _, image := range images {
if image.ID == container.Image {
containerImage = image.RepoTags[0]
break
}
}
conatinerImage := strings.Split(containerImage, ":")[0]
tag := conatinerImage + ":" + time.Now().Format("20060102150405")
ops := dockerTypes.ContainerCommitOptions{
Pause: true,
Config: &containerTypes.Config{
Image: tag,
},
}
// Restart the actual container
_, err = manager.client.ContainerCommit(context.Background(), container.ID, ops)
if err != nil {
return nil, err
}
return &types.RoomSnapshot{
RoomId: id,
Name: tag,
}, nil
}
+2 -1
View File
@@ -29,7 +29,8 @@ func (manager *RoomManagerCtx) allocatePorts(sum uint16) (EprPorts, error) {
}
for _, port := range ports {
if (epr.Min >= port.Min && epr.Min <= port.Max) || (epr.Max >= port.Min && epr.Max <= port.Max) {
if (epr.Min >= port.Min && epr.Min <= port.Max) ||
(epr.Max >= port.Min && epr.Max <= port.Max) {
epr.Min = port.Max + 1
epr.Max = port.Max + sum
}
-165
View File
@@ -1,165 +0,0 @@
package room
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"m1k1o/neko_rooms/internal/types"
"m1k1o/neko_rooms/internal/utils"
"sync"
"time"
dockerTypes "github.com/docker/docker/api/types"
)
type PullConfig struct {
mu sync.Mutex
cancel func()
status types.PullStatus
layers map[string]int
}
func (pull *PullConfig) TryInitialize(cancel func()) bool {
pull.mu.Lock()
defer pull.mu.Unlock()
if pull.status.Active {
cancel()
return false
}
now := time.Now()
pull.cancel = cancel
pull.status = types.PullStatus{
Active: true,
Started: &now,
Layers: []types.PullLayer{},
Status: []string{},
}
pull.layers = map[string]int{}
return true
}
func (pull *PullConfig) SetDone() {
pull.mu.Lock()
defer pull.mu.Unlock()
now := time.Now()
pull.status.Active = false
pull.status.Finished = &now
}
func (pull *PullConfig) Stop() bool {
pull.mu.Lock()
defer pull.mu.Unlock()
if pull.status.Active {
pull.cancel()
}
return pull.status.Active
}
func (pull *PullConfig) Get() types.PullStatus {
pull.mu.Lock()
defer pull.mu.Unlock()
return pull.status
}
func (manager *RoomManagerCtx) PullStart(request types.PullStart) error {
if in, _ := utils.ArrayIn(request.NekoImage, manager.config.NekoImages); !in {
return fmt.Errorf("unknown neko image")
}
ctx, cancel := context.WithCancel(context.Background())
if !manager.pull.TryInitialize(cancel) {
return fmt.Errorf("pull is already in progess")
}
// handle registry auth
var opts dockerTypes.ImagePullOptions
if request.RegistryUser != "" && request.RegistryPass != "" {
authConfig := dockerTypes.AuthConfig{
Username: request.RegistryUser,
Password: request.RegistryPass,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
panic(err)
}
opts = dockerTypes.ImagePullOptions{
RegistryAuth: base64.URLEncoding.EncodeToString(encodedJSON),
}
}
reader, err := manager.client.ImagePull(ctx, request.NekoImage, opts)
if err != nil {
manager.pull.SetDone()
return err
}
go func() {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
data := scanner.Bytes()
layer := types.PullLayer{}
if err := json.Unmarshal(data, &layer); err != nil {
manager.pull.status.Status = append(
manager.pull.status.Status,
fmt.Sprintf("Error while parsing pull response: %s", err),
)
continue
}
if layer.ProgressDetail != nil {
// map layer id to slice index
if index, ok := manager.pull.layers[layer.ID]; ok {
manager.pull.status.Layers[index] = layer
} else {
manager.pull.layers[layer.ID] = len(manager.pull.layers)
manager.pull.status.Layers = append(manager.pull.status.Layers, layer)
}
} else {
manager.pull.status.Status = append(
manager.pull.status.Status,
layer.Status,
)
}
}
if err := scanner.Err(); err != nil {
manager.pull.status.Status = append(
manager.pull.status.Status,
fmt.Sprintf("Error while reading pull response: %s", err),
)
}
reader.Close()
manager.pull.SetDone()
}()
return nil
}
func (manager *RoomManagerCtx) PullStop() error {
if !manager.pull.Stop() {
return fmt.Errorf("pull is not in progess")
}
return nil
}
func (manager *RoomManagerCtx) PullStatus() types.PullStatus {
return manager.pull.Get()
}
+33
View File
@@ -0,0 +1,33 @@
package server
import (
"net/http"
"net/http/pprof"
"github.com/go-chi/chi"
)
const pprofPath = "/debug/pprof/"
func withPProf(router *chi.Mux) {
router.Route(pprofPath, func(r chi.Router) {
r.Get("/", pprof.Index)
r.Get("/{action}", func(w http.ResponseWriter, r *http.Request) {
action := chi.URLParam(r, "action")
switch action {
case "cmdline":
pprof.Cmdline(w, r)
case "profile":
pprof.Profile(w, r)
case "symbol":
pprof.Symbol(w, r)
case "trace":
pprof.Trace(w, r)
default:
pprof.Handler(action).ServeHTTP(w, r)
}
})
})
}
+74
View File
@@ -0,0 +1,74 @@
package server
import (
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"github.com/rs/zerolog"
)
type logformatter struct {
logger zerolog.Logger
}
func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry {
req := map[string]interface{}{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
req["id"] = reqID
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
req["scheme"] = scheme
req["proto"] = r.Proto
req["method"] = r.Method
req["remote"] = r.RemoteAddr
req["agent"] = r.UserAgent()
req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
fields := map[string]interface{}{}
fields["req"] = req
return &logentry{
fields: fields,
logger: l.logger,
}
}
type logentry struct {
logger zerolog.Logger
fields map[string]interface{}
errors []map[string]interface{}
}
func (e *logentry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) {
res := map[string]interface{}{}
res["time"] = time.Now().UTC().Format(time.RFC1123)
res["status"] = status
res["bytes"] = bytes
res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0
e.fields["res"] = res
e.fields["module"] = "http"
if len(e.errors) > 0 {
e.fields["errors"] = e.errors
e.logger.Error().Fields(e.fields).Msgf("request failed (%d)", status)
} else {
e.logger.Debug().Fields(e.fields).Msgf("request complete (%d)", status)
}
}
func (e *logentry) Panic(v interface{}, stack []byte) {
err := map[string]interface{}{}
err["message"] = fmt.Sprintf("%+v", v)
err["stack"] = string(stack)
e.errors = append(e.errors, err)
}
+251
View File
@@ -0,0 +1,251 @@
package server
import (
"context"
"io/ioutil"
"net"
"net/http"
"os"
"path"
"path/filepath"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko-rooms/internal/config"
"github.com/m1k1o/neko-rooms/internal/types"
)
type ServerManagerCtx struct {
logger zerolog.Logger
router *chi.Mux
server *http.Server
config *config.Server
}
func New(ApiManager types.ApiManager, roomConfig *config.Room, config *config.Server, proxyHandler http.Handler) *ServerManagerCtx {
logger := log.With().Str("module", "server").Logger()
router := chi.NewRouter()
router.Use(middleware.RequestID) // Create a request ID for each request
// get real users ip
if config.Proxy {
router.Use(middleware.RealIP)
}
// add http logger
router.Use(middleware.RequestLogger(&logformatter{logger}))
router.Use(middleware.Recoverer) // Recover from panics without crashing server
// Basic CORS
// for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing
router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
// mount pprof endpoint
if config.PProf {
withPProf(router)
logger.Info().Msgf("with pprof endpoint at %s", pprofPath)
}
//
// admin page
//
protected := func(next http.Handler) http.Handler {
// if proxy auth is enabled
if config.Admin.ProxyAuth != "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req, err := http.NewRequest("GET", config.Admin.ProxyAuth, nil)
if err != nil {
logger.Err(err).Msg("proxy auth request `http.NewRequest` err")
http.Error(w, "proxy auth failed", http.StatusForbidden)
return
}
//
// copy headers
//
for k, vv := range r.Header {
for _, v := range vv {
req.Header.Add(k, v)
}
}
//
// add x-forwarded headers
//
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
req.Header.Add("X-Forwarded-For", clientIP)
}
if r.Method != "" {
req.Header.Add("X-Forwarded-Method", r.Method)
} else {
req.Header.Del("X-Forwarded-Method")
}
if r.TLS != nil {
req.Header.Add("X-Forwarded-Proto", "https")
} else {
req.Header.Add("X-Forwarded-Proto", "http")
}
if r.Host != "" {
req.Header.Add("X-Forwarded-Host", r.Host)
} else {
req.Header.Del("X-Forwarded-Host")
}
if r.URL.RequestURI() != "" {
req.Header.Add("X-Forwarded-Uri", r.URL.RequestURI())
} else {
req.Header.Del("X-Forwarded-Uri")
}
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
res, err := client.Do(req)
if err != nil {
logger.Err(err).Msg("proxy auth request `client.Do` err")
http.Error(w, "proxy auth failed", http.StatusForbidden)
return
}
defer res.Body.Close()
// read whole body
body, err := ioutil.ReadAll(res.Body)
if err != nil {
logger.Err(err).Msg("proxy auth request `ioutil.ReadAll` err")
http.Error(w, "proxy auth failed", http.StatusForbidden)
return
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
// copy headers
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
// copy status & body
w.WriteHeader(res.StatusCode)
w.Write(body)
return
}
next.ServeHTTP(w, r)
})
}
// if basic auth is enabled
if config.Admin.Username != "" && config.Admin.Password != "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != config.Admin.Username || pass != config.Admin.Password {
w.Header().Add("WWW-Authenticate", `Basic realm="neko-rooms admin"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
return next
}
// cache static file paths
staticFiles := map[string]struct{}{}
if config.Admin.Static != "" {
filepath.Walk(config.Admin.Static,
func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
staticFiles[path.Clean(p)] = struct{}{}
return nil
})
}
// serve static files
router.Use(func(next http.Handler) http.Handler {
// if static files are disabled
if config.Admin.Static == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
filePath := path.Join(config.Admin.Static, r.URL.Path)
// check if file exists to serve it
if _, ok := staticFiles[filePath]; ok {
// serve protected assets
protected(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filePath)
})).ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
})
// serve protected API
router.With(protected).Route("/api", ApiManager.Mount)
// handle all remaining paths with proxy
router.Handle("/*", proxyHandler)
return &ServerManagerCtx{
logger: logger,
router: router,
server: &http.Server{
Addr: config.Bind,
Handler: router,
},
config: config,
}
}
func (s *ServerManagerCtx) Start() {
if s.config.Cert != "" && s.config.Key != "" {
go func() {
if err := s.server.ListenAndServeTLS(s.config.Cert, s.config.Key); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start https server")
}
}()
s.logger.Info().Msgf("https listening on %s", s.server.Addr)
} else {
go func() {
if err := s.server.ListenAndServe(); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start http server")
}
}()
s.logger.Info().Msgf("http listening on %s", s.server.Addr)
}
}
func (s *ServerManagerCtx) Shutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.server.Shutdown(ctx)
}
-4
View File
@@ -1,13 +1,9 @@
package types
import (
"net/http"
"github.com/go-chi/chi"
)
type ApiManager interface {
Mount(r chi.Router)
RoomLobby(w http.ResponseWriter, r *http.Request)
}
+8
View File
@@ -0,0 +1,8 @@
package types
import "net/http"
type ProxyManager interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
Shutdown() error
}
+33
View File
@@ -0,0 +1,33 @@
package types
import "time"
type PullStart struct {
NekoImage string `json:"neko_image"`
RegistryUser string `json:"registry_user"`
RegistryPass string `json:"registry_pass"`
}
type PullLayer struct {
Status string `json:"status"`
ProgressDetail *struct {
Current int `json:"current"`
Total int `json:"total"`
} `json:"progressDetail"`
Progress string `json:"progress"`
ID string `json:"id"`
}
type PullStatus struct {
Active bool `json:"active"`
Started *time.Time `json:"started"`
Layers []PullLayer `json:"layers"`
Status []string `json:"status"`
Finished *time.Time `json:"finished"`
}
type PullManager interface {
Start(request PullStart) error
Stop() error
Status() PullStatus
}
+12 -31
View File
@@ -6,7 +6,7 @@ import (
"strings"
"time"
"m1k1o/neko_rooms/internal/utils"
"github.com/m1k1o/neko-rooms/internal/utils"
)
var blacklistedEnvs = []string{
@@ -19,6 +19,8 @@ var blacklistedEnvs = []string{
// ignore bunch of envs managed by neko-rooms
"NEKO_BIND",
"NEKO_EPR",
"NEKO_UDPMUX",
"NEKO_TCPMUX",
"NEKO_NAT1TO1",
"NEKO_ICELITE",
}
@@ -27,6 +29,7 @@ type RoomsConfig struct {
Connections uint16 `json:"connections"`
NekoImages []string `json:"neko_images"`
StorageEnabled bool `json:"storage_enabled"`
UsesMux bool `json:"uses_mux"`
}
type RoomEntry struct {
@@ -35,7 +38,7 @@ type RoomEntry struct {
Name string `json:"name"`
NekoImage string `json:"neko_image"`
IsOutdated bool `json:"is_outdated"`
MaxConnections uint16 `json:"max_connections"`
MaxConnections uint16 `json:"max_connections"` // 0 when using mux
Running bool `json:"running"`
Status string `json:"status"`
Created time.Time `json:"created"`
@@ -66,7 +69,7 @@ type RoomResources struct {
type RoomSettings struct {
Name string `json:"name"`
NekoImage string `json:"neko_image"`
MaxConnections uint16 `json:"max_connections"`
MaxConnections uint16 `json:"max_connections"` // 0 when using mux
ControlProtection bool `json:"control_protection"`
ImplicitControl bool `json:"implicit_control"`
@@ -93,6 +96,11 @@ type RoomSettings struct {
BrowserPolicy *BrowserPolicy `json:"browser_policy,omitempty"`
}
type RoomSnapshot struct {
RoomId string `json:"roomId"`
Name string `json:"name"`
}
func (settings *RoomSettings) ToEnv() []string {
env := []string{
fmt.Sprintf("NEKO_PASSWORD=%s", settings.UserPass),
@@ -246,30 +254,6 @@ type RoomMember struct {
Muted bool `json:"muted"`
}
type PullStart struct {
NekoImage string `json:"neko_image"`
RegistryUser string `json:"registry_user"`
RegistryPass string `json:"registry_pass"`
}
type PullLayer struct {
Status string `json:"status"`
ProgressDetail *struct {
Current int `json:"current"`
Total int `json:"total"`
} `json:"progressDetail"`
Progress string `json:"progress"`
ID string `json:"id"`
}
type PullStatus struct {
Active bool `json:"active"`
Started *time.Time `json:"started"`
Layers []PullLayer `json:"layers"`
Status []string `json:"status"`
Finished *time.Time `json:"finished"`
}
type RoomManager interface {
Config() RoomsConfig
List() ([]RoomEntry, error)
@@ -284,8 +268,5 @@ type RoomManager interface {
Start(id string) error
Stop(id string) error
Restart(id string) error
PullStart(request PullStart) error
PullStop() error
PullStatus() PullStatus
Snapshot(id string) (*RoomSnapshot, error)
}
+6 -16
View File
@@ -1,22 +1,12 @@
package utils
import (
"reflect"
)
func ArrayIn[T comparable](val T, array []T) (exists bool, index int) {
exists, index = false, -1
func ArrayIn(val interface{}, array interface{}) (exists bool, index int) {
exists = false
index = -1
switch reflect.TypeOf(array).Kind() {
case reflect.Slice:
s := reflect.ValueOf(array)
for i := 0; i < s.Len(); i++ {
if reflect.DeepEqual(val, s.Index(i).Interface()) {
index = i
exists = true
return
}
for i, a := range array {
if a == val {
exists, index = true, i
return
}
}
+49 -22
View File
@@ -6,14 +6,17 @@ import (
"os/signal"
"runtime"
"m1k1o/neko_rooms/internal/api"
"m1k1o/neko_rooms/internal/config"
"m1k1o/neko_rooms/internal/http"
"m1k1o/neko_rooms/internal/room"
"github.com/docker/docker/client"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/m1k1o/neko-rooms/internal/api"
"github.com/m1k1o/neko-rooms/internal/config"
"github.com/m1k1o/neko-rooms/internal/proxy"
"github.com/m1k1o/neko-rooms/internal/pull"
"github.com/m1k1o/neko-rooms/internal/room"
"github.com/m1k1o/neko-rooms/internal/server"
)
const Header = `&34
@@ -60,7 +63,6 @@ func init() {
Configs: &Configs{
Root: &config.Root{},
Server: &config.Server{},
API: &config.API{},
Room: &config.Room{},
},
}
@@ -98,7 +100,6 @@ func (i *Version) Details() string {
type Configs struct {
Root *config.Root
Server *config.Server
API *config.API
Room *config.Room
}
@@ -106,10 +107,12 @@ type MainCtx struct {
Version *Version
Configs *Configs
logger zerolog.Logger
roomManager *room.RoomManagerCtx
apiManager *api.ApiManagerCtx
httpManager *http.HttpManagerCtx
logger zerolog.Logger
roomManager *room.RoomManagerCtx
pullManager *pull.PullManagerCtx
apiManager *api.ApiManagerCtx
proxyManager *proxy.ProxyManagerCtx
serverManager *server.ServerManagerCtx
}
func (main *MainCtx) Preflight() {
@@ -117,28 +120,52 @@ func (main *MainCtx) Preflight() {
}
func (main *MainCtx) Start() {
client, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
main.logger.Panic().Err(err).Msg("unable to connect to docker client")
} else {
main.logger.Info().Msg("successfully connected to docker client")
}
main.roomManager = room.New(
client,
main.Configs.Room,
)
main.pullManager = pull.New(
client,
main.Configs.Room.NekoImages,
)
main.apiManager = api.New(
main.roomManager,
main.Configs.API,
main.pullManager,
)
main.httpManager = http.New(
main.apiManager,
main.Configs.Server,
main.proxyManager = proxy.New(
client,
main.Configs.Room.InstanceName,
main.Configs.Room.WaitEnabled,
)
main.httpManager.Start()
main.proxyManager.Start()
main.serverManager = server.New(
main.apiManager,
main.Configs.Room,
main.Configs.Server,
main.proxyManager,
)
main.serverManager.Start()
}
func (main *MainCtx) Shutdown() {
if err := main.httpManager.Shutdown(); err != nil {
main.logger.Err(err).Msg("http manager shutdown with an error")
} else {
main.logger.Debug().Msg("http manager shutdown")
}
var err error
err = main.serverManager.Shutdown()
main.logger.Err(err).Msg("server manager shutdown")
err = main.proxyManager.Shutdown()
main.logger.Err(err).Msg("proxy manager shutdown")
}
func (main *MainCtx) ServeCommand(cmd *cobra.Command, args []string) {
@@ -150,7 +177,7 @@ func (main *MainCtx) ServeCommand(cmd *cobra.Command, args []string) {
signal.Notify(quit, os.Interrupt)
sig := <-quit
main.logger.Warn().Msgf("received %s, attempting graceful shutdown: \n", sig)
main.logger.Warn().Msgf("received %s, attempting graceful shutdown.", sig)
main.Shutdown()
main.logger.Info().Msg("shutdown complete")
}
+9
View File
@@ -0,0 +1,9 @@
NEKO_ROOMS_EPR=59000-59049
NEKO_ROOMS_NAT1TO1=192.168.1.20
NEKO_ROOMS_TRAEFIK_DOMAIN=neko-rooms.server.lan
NEKO_ROOMS_TRAEFIK_ENTRYPOINT=websecure
NEKO_ROOMS_TRAEFIK_NETWORK=neko-rooms-traefik
NEKO_ROOMS_TRAEFIK_CERTRESOLVER=lets-encrypt
TZ=Europe/Vienna
+67
View File
@@ -0,0 +1,67 @@
# Installation guide for traefik as reverse proxy
## Zero-knowledge installation
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!
- Rent a VPS with public IP and OS Ubuntu.
- Get a domain name pointing to your IP (you can even get some for free).
- Run install script and follow instructions.
```bash
wget -O neko-rooms.sh https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/install
sudo bash neko-rooms.sh
```
## How to start
You need to have installed `Docker` and `docker-compose`. You need to have a custom domain pointing to your server's IP.
You can watch installation video provided by *Dr R1ck*:
https://www.youtube.com/watch?v=cCmnw-pq0gA
### Installation guide
You only need `.env.example`, `docker-compose.yml` and `traefik/`.
#### Do I need to use traefik?
- 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.
- Traefik is used to forward traffic to the rooms. You can put nginx in front of it.
- See example configuration for [nginx](./nginx).
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`.
### Step 1
Copy `.env.example` to `.env` and customize.
```bash
cp .env.example .env
```
### Step 2
Create `usersfile` with your users:
```bash
touch usersfile
```
And add as many users as you like:
```bash
echo $(htpasswd -nb user password) >> usersfile
```
### Step 3 (HTTPS only)
Create `acme.json`
```bash
touch acme.json
chmod 600 acme.json
```
Update your email in `traefik.yml`.
@@ -1,8 +1,8 @@
version: "3.7"
version: "3.5"
networks:
default:
attachable: "true"
attachable: true
name: "neko-rooms-traefik"
services:
+43
View File
@@ -0,0 +1,43 @@
version: "3.5"
#
# This docker compose needs .env file.
# Copy .env.example to .env and modify.
#
networks:
default:
attachable: "true"
name: "${NEKO_ROOMS_TRAEFIK_NETWORK}"
services:
traefik:
image: "traefik:2.4"
restart: "unless-stopped"
environment:
- "TZ"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik.yml:/etc/traefik/traefik.yml:ro"
- "./usersfile:/usersfile:ro"
- "./acme.json:/acme.json"
- "./config:/config"
neko-rooms:
image: "m1k1o/neko-rooms:latest"
restart: "unless-stopped"
env_file:
- ".env"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
labels:
- "traefik.enable=true"
- "traefik.http.services.neko-rooms-frontend.loadbalancer.server.port=8080"
- "traefik.http.routers.neko-rooms.entrypoints=${NEKO_ROOMS_TRAEFIK_ENTRYPOINT}"
- "traefik.http.routers.neko-rooms.rule=Host(`${NEKO_ROOMS_TRAEFIK_DOMAIN}`)"
- "traefik.http.routers.neko-rooms.tls=true"
- "traefik.http.routers.neko-rooms.tls.certresolver=${NEKO_ROOMS_TRAEFIK_CERTRESOLVER}"
- "traefik.http.routers.neko-rooms.middlewares=basicauth@file"
+11 -12
View File
@@ -166,8 +166,7 @@ while true; do
break
done
mkdir -p "./traefik"
touch "./traefik/usersfile"
touch "./usersfile"
# Users
while true; do
@@ -185,7 +184,7 @@ while true; do
USR_PASS="admin"
fi
htpasswd -nb "${USR_NAME}" "${USR_PASS}" >> traefik/usersfile
htpasswd -nb "${USR_NAME}" "${USR_PASS}" >> usersfile
echo
read -rp "Do you want to add another user? [y/N] " yn
@@ -221,17 +220,17 @@ echo "[Y] Creating env..."
# Download traefik config
#
mkdir -p "./traefik/config"
mkdir -p "./config"
wget -O "./traefik/traefik.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/traefik.yml"
sed -i "s/yourname@example.com/${TRAEFIK_EMAIL}/g" "./traefik/traefik.yml"
wget -O "./traefik.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/traefik.yml"
sed -i "s/yourname@example.com/${TRAEFIK_EMAIL}/g" "./traefik.yml"
wget -O "./traefik/config/middlewares.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/middlewares.yml"
wget -O "./traefik/config/routers.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/routers.yml"
wget -O "./traefik/config/tls.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/tls.yml"
wget -O "./config/middlewares.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/middlewares.yml"
wget -O "./config/routers.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/routers.yml"
wget -O "./config/tls.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/config/tls.yml"
touch "./traefik/acme.json"
chmod 600 "./traefik/acme.json"
touch "./acme.json"
chmod 600 "./acme.json"
echo "[Y] Downloading traefik config..."
@@ -239,7 +238,7 @@ echo "[Y] Downloading traefik config..."
# Download docker compose file
#
wget -O "./docker-compose.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/docker-compose.yml"
wget -O "./docker-compose.yml" "https://raw.githubusercontent.com/m1k1o/neko-rooms/master/traefik/docker-compose.yml"
# Pull neko images
for NEKO_IMAGE in "${NEKO_IMAGES[@]}"; do
@@ -1,4 +1,4 @@
version: "3.7"
version: "3.5"
networks:
default: