diff --git a/OpenApi.yml b/OpenApi.yml index 57b3846..536b02a 100644 --- a/OpenApi.yml +++ b/OpenApi.yml @@ -243,6 +243,20 @@ components: format: datetime example: "2021-03-07T21:56:34Z" + RoomMount: + type: object + properties: + type: + type: string + enum: [ private, template, public ] + example: private + host_path: + type: string + example: /profile + container_path: + type: string + example: /home/neko/.config/chromium + RoomSettings: type: object properties: @@ -291,6 +305,10 @@ components: type: string example: CUSTOM_ENV: custom value + mounts: + type: array + items: + $ref: '#/components/schemas/RoomMount' RoomStats: type: object diff --git a/README.md b/README.md index 4814b27..9280933 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ docker-compose up -d - [x] add GUI - [x] add HTTPS support - [x] add authentication provider for traefik - - [x] allow specifying custom ENV variables (TODO: FE.) - - [ ] allow mounting direcotries for persistent data + - [x] allow specifying custom ENV variables + - [x] allow mounting direcotries for persistent data - [ ] add upgrade button - [ ] auto pull images, that do not exist - [ ] add bearer token to for API diff --git a/client/src/api/.openapi-generator/VERSION b/client/src/api/.openapi-generator/VERSION index d509cc9..6555596 100644 --- a/client/src/api/.openapi-generator/VERSION +++ b/client/src/api/.openapi-generator/VERSION @@ -1 +1 @@ -5.1.1-SNAPSHOT \ No newline at end of file +5.2.0-SNAPSHOT \ No newline at end of file diff --git a/client/src/api/api.ts b/client/src/api/api.ts index da882f9..18c1af1 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -107,6 +107,42 @@ export interface RoomMember { */ muted?: boolean; } +/** + * + * @export + * @interface RoomMount + */ +export interface RoomMount { + /** + * + * @type {string} + * @memberof RoomMount + */ + type?: RoomMountTypeEnum; + /** + * + * @type {string} + * @memberof RoomMount + */ + host_path?: string; + /** + * + * @type {string} + * @memberof RoomMount + */ + container_path?: string; +} + +/** + * @export + * @enum {string} + */ +export enum RoomMountTypeEnum { + private = 'private', + template = 'template', + public = 'public' +} + /** * * @export @@ -203,6 +239,12 @@ export interface RoomSettings { * @memberof RoomSettings */ envs?: { [key: string]: string; }; + /** + * + * @type {Array} + * @memberof RoomSettings + */ + mounts?: Array; } /** * diff --git a/client/src/components/RoomInfo.vue b/client/src/components/RoomInfo.vue index 7ca72fd..cee2be0 100644 --- a/client/src/components/RoomInfo.vue +++ b/client/src/components/RoomInfo.vue @@ -145,6 +145,15 @@ + +
Mounts
+ + + diff --git a/client/src/components/RoomsCreate.vue b/client/src/components/RoomsCreate.vue index 7467f02..f94e18b 100644 --- a/client/src/components/RoomsCreate.vue +++ b/client/src/components/RoomsCreate.vue @@ -14,7 +14,7 @@ @@ -187,7 +187,7 @@ mdi-plus - + - +
:
+ mdi-close
+ +

Mounts

+ mdi-plus +
+ + + + + + + +
:
+ + + +
+ mdi-close +
+
+ +

+ Private: Host path is relative to <storage path>/rooms/<room name>/.
+ Template: Host path is relative to <storage path>/templates/ and is readonly.
+ Public: Host path must be whitelisted in config and exists on the host. +

+
@@ -272,15 +316,24 @@ export default class RoomsCreate extends Vue { required(val: any) { return val === null || typeof val === 'undefined' || val === "" ? 'This filed is mandatory.' : true }, + minLen: (min: number) => + (val: string) => + val ? (val.length >= min || 'This field must have atleast ' + min + ' characters') : true, onlyPositive(val: number) { return val < 0 ? 'Value cannot be negative.' : true }, nonZero(val: string) { return val === "0" ? 'Value cannot be zero.' : true }, - slug(val: string) { - return val && !/^[A-Za-z0-9-_.]+$/.test(val) ? 'Should only contain A-Z a-z 0-9 - _ .' : true + containerName(val: string) { + return val && !/^[a-zA-Z0-9_.-]+$/.test(val) ? 'Must only contain a-z A-Z 0-9 _ . -' : true + }, + containerNameStart(val: string) { + return val && /^[_.-]/.test(val) ? 'Cannot start with _ . -' : true }, + absolutePath(val: string) { + return val[0] !== "/" ? 'Must be absolute path, starting with /.' : true + } } get nekoImages() { @@ -299,6 +352,23 @@ export default class RoomsCreate extends Vue { return this.$store.state.availableScreens } + get mountTypes() { + return [ + { + text: 'Private', + value: 'private', + }, + { + text: 'Template', + value: 'template', + }, + { + text: 'Public', + value: 'public', + }, + ] + } + addEnv() { Vue.set(this, 'envList', [ ...this.envList, { key: '', val: '' } ]) } diff --git a/client/src/store/state.ts b/client/src/store/state.ts index 9ec5685..d10cd89 100644 --- a/client/src/store/state.ts +++ b/client/src/store/state.ts @@ -37,6 +37,11 @@ export const state = { // eslint-disable-next-line broadcast_pipeline: '', + + // eslint-disable-next-line + envs: {}, + // eslint-disable-next-line + mounts: [], } as RoomSettings, videoCodecs: [ "VP8", diff --git a/dev/.gitignore b/dev/.gitignore new file mode 100644 index 0000000..f3d333e --- /dev/null +++ b/dev/.gitignore @@ -0,0 +1,2 @@ +data +ext diff --git a/dev/start b/dev/start index 0955193..69f345a 100755 --- a/dev/start +++ b/dev/start @@ -9,12 +9,22 @@ 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 "${PWD}/../:/app" \ + -v "`realpath ..`:/app" \ + -v "`realpath ${DATA_PATH}`:/data" \ -e "TZ=${TZ}" \ -e "NEKO_ROOMS_EPR=${NEKO_ROOMS_EPR}" \ -e "NEKO_ROOMS_NAT1TO1=${NEKO_ROOMS_NAT1TO1}" \ + -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}" \ diff --git a/internal/config/room.go b/internal/config/room.go index 0b1bf94..2c45ac7 100644 --- a/internal/config/room.go +++ b/internal/config/room.go @@ -1,9 +1,11 @@ package config import ( + "path/filepath" "strconv" "strings" + dockerNames "github.com/docker/docker/daemon/names" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -16,6 +18,12 @@ type Room struct { NAT1To1IPs []string NekoImages []string + StorageEnabled bool + StorageInternal string + StorageExternal string + + MountsWhitelist []string + InstanceName string InstanceUrl string @@ -50,6 +58,28 @@ func (Room) Init(cmd *cobra.Command) error { return err } + // Data + + cmd.PersistentFlags().Bool("storage.enabled", true, "whether storage is enabled, where peristent containers data will be stored") + if err := viper.BindPFlag("storage.enabled", cmd.PersistentFlags().Lookup("storage.enabled")); err != nil { + return err + } + + cmd.PersistentFlags().String("storage.external", "", "external absolute path (on the host) to storage folder") + if err := viper.BindPFlag("storage.external", cmd.PersistentFlags().Lookup("storage.external")); err != nil { + return err + } + + cmd.PersistentFlags().String("storage.internal", "/data", "internal absolute path (inside container) to storage folder") + if err := viper.BindPFlag("storage.internal", cmd.PersistentFlags().Lookup("storage.internal")); err != nil { + return err + } + + cmd.PersistentFlags().StringSlice("mounts.whitelist", []string{}, "whitelisted public mounts for containers") + if err := viper.BindPFlag("mounts.whitelist", cmd.PersistentFlags().Lookup("mounts.whitelist")); err != nil { + return err + } + // Instance cmd.PersistentFlags().String("instance.name", "neko-rooms", "unique instance name (if running muliple on the same host)") @@ -120,7 +150,36 @@ func (s *Room) Set() { s.NAT1To1IPs = viper.GetStringSlice("nat1to1") s.NekoImages = viper.GetStringSlice("neko_images") + s.StorageEnabled = viper.GetBool("storage.enabled") + s.StorageInternal = viper.GetString("storage.internal") + s.StorageExternal = viper.GetString("storage.external") + + if s.StorageInternal != "" && s.StorageExternal != "" { + s.StorageInternal = filepath.Clean(s.StorageInternal) + s.StorageExternal = filepath.Clean(s.StorageExternal) + + if !filepath.IsAbs(s.StorageInternal) || !filepath.IsAbs(s.StorageExternal) { + log.Panic().Msg("invalid `storage.internal` or `storage.external`, must be an absolute path") + } + } else { + log.Warn().Msg("missing `storage.internal` or `storage.external`, storage is unavailable") + s.StorageEnabled = false + } + + s.MountsWhitelist = viper.GetStringSlice("mounts.whitelist") + for _, path := range s.MountsWhitelist { + path = filepath.Clean(path) + + if !filepath.IsAbs(path) { + log.Panic().Msg("invalid `mounts.whitelist`, must be an absolute path") + } + } + s.InstanceName = viper.GetString("instance.name") + if !dockerNames.RestrictedNamePattern.MatchString(s.InstanceName) { + log.Panic().Msg("invalid `instance.name`, must match " + dockerNames.RestrictedNameChars) + } + s.InstanceUrl = viper.GetString("instance.url") s.TraefikDomain = viper.GetString("traefik.domain") diff --git a/internal/room/manager.go b/internal/room/manager.go index 1f11e05..ee8d24b 100644 --- a/internal/room/manager.go +++ b/internal/room/manager.go @@ -4,13 +4,18 @@ import ( "context" "encoding/json" "fmt" + "os" + "path" + "path/filepath" "strings" dockerTypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" + dockerMount "github.com/docker/docker/api/types/mount" network "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/strslice" dockerClient "github.com/docker/docker/client" + dockerNames "github.com/docker/docker/daemon/names" "github.com/docker/go-connections/nat" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -21,7 +26,11 @@ import ( ) const ( - frontendPort = 8080 + frontendPort = 8080 + templateStoragePath = "./templates" + privateStoragePath = "./rooms" + privateStorageUid = 1000 + privateStorageGid = 1000 ) func New(config *config.Room) *RoomManagerCtx { @@ -83,6 +92,14 @@ func (manager *RoomManagerCtx) FindByName(name string) (*types.RoomEntry, error) } func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, error) { + if settings.Name != "" && !dockerNames.RestrictedNamePattern.MatchString(settings.Name) { + return "", fmt.Errorf("invalid container name, must match " + dockerNames.RestrictedNameChars) + } + + if !manager.config.StorageEnabled && len(settings.Mounts) > 0 { + return "", fmt.Errorf("mounts cannot be specified, because storage is disabled or unavailable") + } + if in, _ := utils.ArrayIn(settings.NekoImage, manager.config.NekoImages); !in { return "", fmt.Errorf("invalid neko image") } @@ -177,6 +194,70 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro env = append(env, fmt.Sprintf("NEKO_NAT1TO1=%s", strings.Join(manager.config.NAT1To1IPs, ","))) } + mounts := []dockerMount.Mount{} + for _, mount := range settings.Mounts { + readOnly := false + + hostPath := filepath.Clean(mount.HostPath) + containerPath := filepath.Clean(mount.ContainerPath) + + if !filepath.IsAbs(hostPath) || !filepath.IsAbs(containerPath) { + return "", fmt.Errorf("mount paths must be absolute") + } + + // private container's data + if mount.Type == types.MountPrivate { + // ensure that target exists + internalPath := path.Join(manager.config.StorageInternal, privateStoragePath, roomName, hostPath) + if err := os.MkdirAll(internalPath, os.ModePerm); err != nil { + return "", err + } + + if err := utils.ChownR(internalPath, privateStorageUid, privateStorageGid); err != nil { + return "", err + } + + // prefix host path + hostPath = path.Join(manager.config.StorageExternal, privateStoragePath, roomName, hostPath) + } else if mount.Type == types.MountTemplate { + // readonly template data + readOnly = true + + // prefix host path + hostPath = path.Join(manager.config.StorageExternal, templateStoragePath, hostPath) + } else if mount.Type == types.MountPublic { + // public whitelisted mounts + var isAllowed = false + for _, path := range manager.config.MountsWhitelist { + if strings.HasPrefix(hostPath, path) { + isAllowed = true + break + } + } + + if !isAllowed { + return "", fmt.Errorf("mount path is not whitelisted in config") + } + } else { + return "", fmt.Errorf("unknown mount type %q", mount.Type) + } + + mounts = append(mounts, + dockerMount.Mount{ + Type: dockerMount.TypeBind, + Source: hostPath, + Target: containerPath, + ReadOnly: readOnly, + Consistency: dockerMount.ConsistencyDefault, + + BindOptions: &dockerMount.BindOptions{ + Propagation: dockerMount.PropagationRPrivate, + NonRecursive: false, + }, + }, + ) + } + config := &container.Config{ // Hostname Hostname: containerName, @@ -211,6 +292,8 @@ func (manager *RoomManagerCtx) Create(settings types.RoomSettings) (string, erro }, // Total shm memory usage ShmSize: 2 * 10e9, + // Mounts specs used by the container + Mounts: mounts, } networkingConfig := &network.NetworkingConfig{ @@ -295,10 +378,34 @@ func (manager *RoomManagerCtx) GetSettings(id string) (*types.RoomSettings, erro return nil, err } + privateStorageRoot := path.Join(manager.config.StorageExternal, privateStoragePath, roomName) + templateStorageRoot := path.Join(manager.config.StorageExternal, templateStoragePath) + + mounts := []types.RoomMount{} + for _, mount := range container.Mounts { + mountType := types.MountPublic + hostPath := mount.Source + + if strings.HasPrefix(hostPath, privateStorageRoot) { + mountType = types.MountPrivate + hostPath = strings.TrimPrefix(hostPath, privateStorageRoot) + } else if strings.HasPrefix(hostPath, templateStorageRoot) { + mountType = types.MountTemplate + hostPath = strings.TrimPrefix(hostPath, templateStorageRoot) + } + + mounts = append(mounts, types.RoomMount{ + Type: mountType, + HostPath: hostPath, + ContainerPath: mount.Destination, + }) + } + settings := types.RoomSettings{ Name: roomName, NekoImage: nekoImage, MaxConnections: epr.Max - epr.Min + 1, + Mounts: mounts, } err = settings.FromEnv(container.Config.Env) diff --git a/internal/types/room.go b/internal/types/room.go index 04dcefd..f6808af 100644 --- a/internal/types/room.go +++ b/internal/types/room.go @@ -39,6 +39,20 @@ type RoomEntry struct { Created time.Time `json:"created"` } +type MountType string + +const ( + MountPrivate MountType = "private" + MountTemplate MountType = "template" + MountPublic MountType = "public" +) + +type RoomMount struct { + Type MountType `json:"type"` + HostPath string `json:"host_path"` + ContainerPath string `json:"container_path"` +} + type RoomSettings struct { Name string `json:"name"` NekoImage string `json:"neko_image"` @@ -59,7 +73,8 @@ type RoomSettings struct { BroadcastPipeline string `json:"broadcast_pipeline,omitempty"` - Envs map[string]string `json:"envs"` + Envs map[string]string `json:"envs"` + Mounts []RoomMount `json:"mounts"` } func (settings *RoomSettings) ToEnv() []string { diff --git a/internal/utils/fs.go b/internal/utils/fs.go new file mode 100644 index 0000000..cc24a2c --- /dev/null +++ b/internal/utils/fs.go @@ -0,0 +1,15 @@ +package utils + +import ( + "os" + "path/filepath" +) + +func ChownR(path string, uid, gid int) error { + return filepath.Walk(path, func(name string, info os.FileInfo, err error) error { + if err == nil { + err = os.Chown(name, uid, gid) + } + return err + }) +}