55 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
56 changed files with 3612 additions and 2414 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:
+6 -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
@@ -116,6 +71,7 @@ For more information visit [docs](./docs).
- [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() {
-1
View File
@@ -18,7 +18,6 @@ func init() {
configs := []config.Config{
nekoRooms.Service.Configs.Server,
nekoRooms.Service.Configs.API,
nekoRooms.Service.Configs.Room,
}
+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;
+4 -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}";
@@ -22,7 +22,7 @@ docker run --rm -it \
--name="neko_rooms_traefik" \
-p "${1:-80}:80" \
-p "8080:8080" \
-v "${PWD}/../:/app" \
-v "${PWD}/../../:/app" \
-e "TZ=${TZ}" \
-v "/var/run/docker.sock:/var/run/docker.sock" \
--net="${NEKO_ROOMS_TRAEFIK_NETWORK}" \
+13 -32
View File
@@ -1,43 +1,24 @@
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}"
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"
env_file:
- ".env"
environment:
- "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"
+8
View File
@@ -21,3 +21,11 @@ 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
```
+26 -28
View File
@@ -1,52 +1,50 @@
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
+2 -4
View File
@@ -5,7 +5,6 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko-rooms/internal/config"
"github.com/m1k1o/neko-rooms/internal/types"
)
@@ -13,15 +12,13 @@ type ApiManagerCtx struct {
logger zerolog.Logger
rooms types.RoomManager
pull types.PullManager
conf *config.API
}
func New(rooms types.RoomManager, pull types.PullManager, conf *config.API) *ApiManagerCtx {
func New(rooms types.RoomManager, pull types.PullManager) *ApiManagerCtx {
return &ApiManagerCtx{
logger: log.With().Str("module", "api").Logger(),
rooms: rooms,
pull: pull,
conf: conf,
}
}
@@ -60,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)
})
}
+12
View File
@@ -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")
}
+79 -31
View File
@@ -13,7 +13,16 @@ 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
@@ -22,6 +31,7 @@ type Room struct {
NekoPrivilegedImages []string
PathPrefix string
Labels []string
WaitEnabled bool
StorageEnabled bool
StorageInternal string
@@ -29,17 +39,19 @@ type Room struct {
MountsWhitelist []string
InstanceName string
InstanceUrl *url.URL
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
@@ -57,6 +69,7 @@ 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:remmina",
@@ -71,7 +84,7 @@ func (Room) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().String("path_prefix", "", "path prefix that is added to every room path")
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
}
@@ -81,6 +94,11 @@ func (Room) Init(cmd *cobra.Command) error {
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")
@@ -115,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
@@ -132,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
}
@@ -146,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")
@@ -173,8 +203,9 @@ func (s *Room) Set() {
s.NAT1To1IPs = viper.GetStringSlice("nat1to1")
s.NekoImages = viper.GetStringSlice("neko_images")
s.NekoPrivilegedImages = viper.GetStringSlice("neko_privileged_images")
s.PathPrefix = viper.GetString("path_prefix")
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")
@@ -215,18 +246,33 @@ func (s *Room) Set() {
}
}
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 != 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`")
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`")
}
}
}
}
@@ -242,17 +288,19 @@ func (s *Room) GetInstanceUrl() url.URL {
Path: "/",
}
if s.TraefikCertresolver != "" {
instanceUrl.Scheme = "https"
}
if s.Traefik.Enabled {
if s.Traefik.Certresolver != "" {
instanceUrl.Scheme = "https"
}
if s.TraefikDomain != "" && s.TraefikDomain != "*" {
instanceUrl.Host = s.TraefikDomain
}
if s.Traefik.Domain != "" && s.Traefik.Domain != "*" {
instanceUrl.Host = s.Traefik.Domain
}
// deprecated
if s.TraefikPort != "" {
instanceUrl.Host += ":" + s.TraefikPort
// deprecated
if s.Traefik.Port != "" {
instanceUrl.Host += ":" + s.Traefik.Port
}
}
return instanceUrl
@@ -264,7 +312,7 @@ func (s *Room) GetRoomUrl(roomName string) string {
if strings.HasPrefix(instanceUrl.Host, "*.") {
instanceUrl.Host = roomName + "." + strings.TrimPrefix(instanceUrl.Host, "*.")
} else {
instanceUrl.Path = path.Join(instanceUrl.Path, s.PathPrefix, roomName, "/")
instanceUrl.Path = path.Join(instanceUrl.Path, s.PathPrefix, roomName) + "/"
}
return instanceUrl.String()
+41 -12
View File
@@ -5,13 +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
Proxy bool
PProf bool
Cert string
Key string
Bind string
Proxy bool
PProf bool
Admin Admin
}
func (Server) Init(cmd *cobra.Command) error {
@@ -30,11 +38,6 @@ 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 {
return err
}
cmd.PersistentFlags().Bool("proxy", false, "trust reverse proxy headers")
if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil {
return err
@@ -45,6 +48,28 @@ func (Server) Init(cmd *cobra.Command) error {
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
}
return nil
}
@@ -52,7 +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")
}
+5 -5
View File
@@ -36,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
+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": [
"*"
]
}
@@ -1,41 +1,46 @@
package api
package proxy
import (
"net/http"
"strings"
"github.com/go-chi/chi"
"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
}
+8 -2
View File
@@ -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) {
+53 -23
View File
@@ -10,6 +10,7 @@ import (
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)
+146 -56
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"
@@ -55,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,
}
}
@@ -86,6 +89,21 @@ 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 %s", dockerNames.RestrictedNameChars)
@@ -95,11 +113,11 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
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, _ := utils.ArrayIn(settings.NekoImage, manager.config.NekoPrivilegedImages)
isPrivilegedImage := manager.IsValidImage(settings.NekoImage, manager.config.NekoPrivilegedImages)
// TODO: Check if path name exists.
roomName := settings.Name
@@ -111,73 +129,48 @@ 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/tcp", 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
pathPrefix := path.Join("/", manager.config.PathPrefix, roomName)
// create traefik rule
traefikRule := "PathPrefix(`" + pathPrefix + "`)"
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": pathPrefix + "$$",
"traefik.http.middlewares." + containerName + "-rdr.redirectregex.replacement": pathPrefix + "/",
"traefik.http.middlewares." + containerName + "-prf.stripprefix.prefixes": pathPrefix + "/",
"traefik.http.routers." + containerName + ".middlewares": containerName + "-rdr," + containerName + "-prf",
"traefik.http.routers." + containerName + ".service": containerName + "-frontend",
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{}{}
}
//
@@ -194,15 +187,56 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
labels := manager.serializeLabels(RoomLabels{
Name: roomName,
URL: manager.config.GetRoomUrl(roomName),
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
@@ -210,8 +244,11 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
// replace dynamic values in labels
label = strings.Replace(label, "{containerName}", containerName, -1)
label = strings.Replace(label, "{roomName}", roomName, -1)
label = strings.Replace(label, "{traefikEntrypoint}", manager.config.TraefikEntrypoint, -1)
label = strings.Replace(label, "{traefikCertresolver}", manager.config.TraefikCertresolver, -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 {
@@ -229,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, ",")))
@@ -410,7 +457,7 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro
networkingConfig := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
manager.config.TraefikNetwork: {},
manager.config.InstanceNetwork: {},
},
}
@@ -543,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
}
@@ -603,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
}
+159 -21
View File
@@ -2,9 +2,12 @@ package server
import (
"context"
"io/ioutil"
"net"
"net/http"
"os"
"path"
"path/filepath"
"time"
"github.com/go-chi/chi"
@@ -24,7 +27,7 @@ type ServerManagerCtx struct {
config *config.Server
}
func New(ApiManager types.ApiManager, pathPrefix string, config *config.Server) *ServerManagerCtx {
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()
@@ -49,32 +52,167 @@ func New(ApiManager types.ApiManager, pathPrefix string, config *config.Server)
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
router.Route("/api", ApiManager.Mount)
// serve static files
if config.Static != "" {
fs := http.FileServer(http.Dir(config.Static))
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
if _, err := os.Stat(config.Static + r.URL.Path); !os.IsNotExist(err) {
fs.ServeHTTP(w, r)
} else {
http.NotFound(w, r)
}
})
}
// add simple lobby room
router.Get(path.Join("/", pathPrefix, "{roomName}"), ApiManager.RoomLobby)
router.Get(path.Join("/", pathPrefix, "{roomName}")+"/", ApiManager.RoomLobby)
// mount pprof endpoint
if config.PProf {
withPProf(router)
logger.Info().Msgf("with pprof endpoint at %s", pprofPath)
}
// we could use custom 404
router.NotFound(http.NotFound)
//
// 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,
-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
}
+11 -2
View File
@@ -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),
@@ -260,4 +268,5 @@ type RoomManager interface {
Start(id string) error
Stop(id string) error
Restart(id string) error
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
}
}
+17 -5
View File
@@ -13,6 +13,7 @@ import (
"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"
@@ -62,7 +63,6 @@ func init() {
Configs: &Configs{
Root: &config.Root{},
Server: &config.Server{},
API: &config.API{},
Room: &config.Room{},
},
}
@@ -100,7 +100,6 @@ func (i *Version) Details() string {
type Configs struct {
Root *config.Root
Server *config.Server
API *config.API
Room *config.Room
}
@@ -112,6 +111,7 @@ type MainCtx struct {
roomManager *room.RoomManagerCtx
pullManager *pull.PullManagerCtx
apiManager *api.ApiManagerCtx
proxyManager *proxy.ProxyManagerCtx
serverManager *server.ServerManagerCtx
}
@@ -140,20 +140,32 @@ func (main *MainCtx) Start() {
main.apiManager = api.New(
main.roomManager,
main.pullManager,
main.Configs.API,
)
main.proxyManager = proxy.New(
client,
main.Configs.Room.InstanceName,
main.Configs.Room.WaitEnabled,
)
main.proxyManager.Start()
main.serverManager = server.New(
main.apiManager,
main.Configs.Room.PathPrefix,
main.Configs.Room,
main.Configs.Server,
main.proxyManager,
)
main.serverManager.Start()
}
func (main *MainCtx) Shutdown() {
err := main.serverManager.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) {
+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`.
+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