17 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
21 changed files with 614 additions and 73 deletions
+31 -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:
@@ -477,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:
+90
View File
@@ -597,6 +597,30 @@ export interface 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
*/
}
/**
* ConfigApi - axios parameter creator
@@ -1254,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};
@@ -1382,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);
},
}
};
@@ -1491,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));
},
};
};
@@ -1619,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
+38 -23
View File
@@ -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,24 +99,10 @@
<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>
@@ -199,6 +204,7 @@
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import RoomLink from './RoomLink.vue'
import {
RoomStats,
@@ -207,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)
}
@@ -227,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({
@@ -252,6 +263,7 @@ export default class RoomInfo extends Vue {
}
}
@Watch('room.status')
async LoadStats() {
this.statsLoading = true
@@ -272,6 +284,9 @@ export default class RoomInfo extends Vue {
return 0
})
this.stats = stats
this.statsErr = ""
} catch (e: any) {
this.statsErr = e
} finally {
this.statsLoading = false
}
+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>
+4 -5
View File
@@ -551,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 }
})
}
+2 -1
View File
@@ -37,7 +37,7 @@
<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" />
+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
View File
@@ -57,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)
}
+7
View File
@@ -31,6 +31,7 @@ type Room struct {
NekoPrivilegedImages []string
PathPrefix string
Labels []string
WaitEnabled bool
StorageEnabled bool
StorageInternal string
@@ -93,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")
@@ -199,6 +205,7 @@ func (s *Room) Set() {
s.NekoPrivilegedImages = viper.GetStringSlice("neko_privileged_images")
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
s.Labels = viper.GetStringSlice("labels")
s.WaitEnabled = viper.GetBool("wait_enabled")
s.StorageEnabled = viper.GetBool("storage.enabled")
s.StorageInternal = viper.GetString("storage.internal")
+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": [
"*"
]
}
+52 -2
View File
@@ -6,7 +6,41 @@ import (
"github.com/m1k1o/neko-rooms/internal/utils"
)
func RoomNotFound(w http.ResponseWriter, r *http.Request) {
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'
let lastAttempt = (new Date()).getTime()
while(true) {
try {
lastAttempt = (new Date()).getTime()
document.querySelector(".swal2-loader").style.visibility = 'visible'
await fetch("?wait")
location.href = location.href
} catch {
let now = (new Date()).getTime()
let diff = now - lastAttempt
// if the gap between last attempt and now
// is gt 20s, do reconnect immediatly
if ((now - lastAttempt) > 20*1000) {
continue
}
// 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 RoomNotFound(w http.ResponseWriter, r *http.Request, waitEnabled bool) {
utils.Swal2Response(w, `
<div class="swal2-header">
<div class="swal2-icon swal2-error">
@@ -16,11 +50,19 @@ func RoomNotFound(w http.ResponseWriter, r *http.Request) {
</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 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">
@@ -30,8 +72,16 @@ func RoomNotRunning(w http.ResponseWriter, r *http.Request) {
</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 RoomNotReady(w http.ResponseWriter, r *http.Request) {
+78 -7
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"path"
"strconv"
"strings"
"sync"
@@ -23,23 +24,34 @@ type entry struct {
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) *ProxyManagerCtx {
func New(client *dockerClient.Client, instanceName string, waitEnabled bool) *ProxyManagerCtx {
return &ProxyManagerCtx{
logger: log.With().Str("module", "proxy").Logger(),
logger: log.With().Str("module", "proxy").Logger(),
waitChans: map[string]*wait{},
client: client,
instanceName: instanceName,
waitEnabled: waitEnabled,
handlers: &prefixHandler[*entry]{},
}
}
@@ -83,6 +95,17 @@ func (p *ProxyManagerCtx) Start() {
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":
@@ -222,28 +245,76 @@ func (p *ProxyManagerCtx) parseLabels(labels map[string]string) (enabled bool, p
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(r.URL.Path)
proxy, prefix, ok := p.handlers.Match(cleanPath)
p.mu.RUnlock()
// if room not found
if !ok {
RoomNotFound(w, r)
// 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 r.URL.Path == prefix {
r.URL.Path += "/"
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 {
RoomNotRunning(w, r)
// 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
}
+59 -3
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"
@@ -87,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)
@@ -96,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
@@ -637,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
}
+6
View File
@@ -96,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),
@@ -263,4 +268,5 @@ type RoomManager interface {
Start(id string) error
Stop(id string) error
Restart(id string) error
Snapshot(id string) (*RoomSnapshot, error)
}
+1
View File
@@ -145,6 +145,7 @@ func (main *MainCtx) Start() {
main.proxyManager = proxy.New(
client,
main.Configs.Room.InstanceName,
main.Configs.Room.WaitEnabled,
)
main.proxyManager.Start()
+10 -11
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..."