diff --git a/src/api.ts b/src/api.ts index fadf204..d596e90 100644 --- a/src/api.ts +++ b/src/api.ts @@ -21,6 +21,7 @@ export interface Config { export interface Version { version: string + plugins: string[] } export interface InstanceInfo { @@ -31,6 +32,7 @@ export interface Gateway { host: string port: number url: string + auth_token: string } @Injectable({ providedIn: 'root' }) diff --git a/src/components/configModal.component.pug b/src/components/configModal.component.pug index a37b0fe..28bd806 100644 --- a/src/components/configModal.component.pug +++ b/src/components/configModal.component.pug @@ -31,7 +31,7 @@ .list-group.list-group-light ng-container(*ngFor='let config of configService.configs') button.list-group-item.list-group-item-action( - *ngIf='config !== configService.activeConfig', + *ngIf='config.id !== configService.activeConfig?.id', (click)='selectConfig(config)' ) fa-icon([icon]='_configIcon') diff --git a/src/components/configModal.component.ts b/src/components/configModal.component.ts index f0031ae..8573421 100644 --- a/src/components/configModal.component.ts +++ b/src/components/configModal.component.ts @@ -46,4 +46,11 @@ export class ConfigModalComponent { this.modalInstance.dismiss() } + async deleteConfig () { + if (confirm('Delete this config? This cannot be undone.')) { + await this.configService.deleteConfig(this.configService.activeConfig) + } + this.configService.selectDefaultConfig() + this.modalInstance.dismiss() + } } diff --git a/src/services/appConnector.service.ts b/src/services/appConnector.service.ts index d9138ab..5f46abd 100644 --- a/src/services/appConnector.service.ts +++ b/src/services/appConnector.service.ts @@ -13,6 +13,7 @@ export class SocketProxy { close$ = new Subject() url: string + authToken: string webSocket: WebSocket initialBuffer: Buffer options: { @@ -27,15 +28,27 @@ export class SocketProxy { async connect (options) { this.options = options this.url = this.appConnector.loginService.user.custom_connection_gateway + this.authToken = this.appConnector.loginService.user.custom_connection_gateway_token if (!this.url) { try { - this.url = (await this.appConnector.chooseConnectionGateway()).url + const gateway = await this.appConnector.chooseConnectionGateway() + this.url = gateway.url + this.authToken = gateway.auth_token } catch (err) { this.error$.next(err) return } } - this.webSocket = new WebSocket(this.url) + try { + this.webSocket = new WebSocket(this.url) + } catch (err) { + this.error$.next(err) + return + } + this.webSocket.onerror = err => { + this.error$.next(new Error(`Failed to connect to the connection gateway at ${this.url}`)) + return + } this.webSocket.onmessage = async event => { if (typeof(event.data) === 'string') { this.handleServiceMessage(JSON.parse(event.data)) @@ -53,7 +66,7 @@ export class SocketProxy { this.sendServiceMessage({ _: 'hello', version: 1, - auth_token: this.appConnector.loginService.user.custom_connection_gateway_token, + auth_token: this.authToken, }) } else if (msg._ === 'ready') { this.sendServiceMessage({ @@ -139,7 +152,7 @@ export class AppConnectorService { } getPluginsToLoad (): string[] { - return [ + const loadOrder = [ 'tabby-core', 'tabby-settings', 'tabby-terminal', @@ -147,6 +160,11 @@ export class AppConnectorService { 'tabby-community-color-schemes', 'tabby-web', ] + + return [ + ...loadOrder.filter(x => this.version.plugins.includes(x)), + ...this.version.plugins.filter(x => !loadOrder.includes(x)), + ] } createSocket () { diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 9c896cd..bcbcfc8 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -76,6 +76,11 @@ export class ConfigService { this.selectConfig(this.configs.find(c => c.id === this.loginService.user.active_config) ?? this.configs[0]) } + async deleteConfig (config: Config) { + await this.http.delete(`/api/1/configs/${config.id}`).toPromise() + this.configs = this.configs.filter(x => x.id !== config.id) + } + private async init () { this.configs = await this.http.get('/api/1/configs').toPromise() this.versions = await this.http.get('/api/1/versions').toPromise() diff --git a/tabby/app/api.py b/tabby/app/api.py index 220011c..7d99a28 100644 --- a/tabby/app/api.py +++ b/tabby/app/api.py @@ -1,8 +1,10 @@ -import os +import asyncio import random -from dataclasses import dataclass +from tabby.app.consumers import GatewayAdminConnection from django.conf import settings from django.contrib.auth import logout +from dataclasses import dataclass +from pathlib import Path from rest_framework import fields from rest_framework.exceptions import PermissionDenied, NotFound from rest_framework.permissions import IsAuthenticated @@ -12,6 +14,7 @@ from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet, ModelViewSet from rest_framework.serializers import ModelSerializer, Serializer from rest_framework_dataclasses.serializers import DataclassSerializer +from typing import List from .models import Config, Gateway, User @@ -19,6 +22,7 @@ from .models import Config, Gateway, User @dataclass class AppVersion: version: str + plugins: List[str] class AppVersionSerializer(DataclassSerializer): @@ -28,6 +32,7 @@ class AppVersionSerializer(DataclassSerializer): class GatewaySerializer(ModelSerializer): url = fields.SerializerMethodField() + auth_token = fields.CharField() class Meta: fields = '__all__' @@ -65,13 +70,29 @@ class AppVersionViewSet(ListModelMixin, GenericViewSet): queryset = '' def _get_versions(self): - return [AppVersion(version=x) for x in os.listdir(settings.APP_DIST_PATH)] + return [self._get_version(x) for x in settings.APP_DIST_PATH.iterdir()] + + def _get_version(self, dir: Path): + plugins = [ + x.name for x in dir.iterdir() + if x.is_dir() and x.name not in [ + 'tabby-web-container', + 'tabby-web-demo', + ] + ] + + return AppVersion( + version=dir.name, + plugins=plugins, + ) def list(self, request, *args, **kwargs): - return Response(self.serializer_class( - self._get_versions(), - many=True, - ).data) + return Response( + self.serializer_class( + self._get_versions(), + many=True, + ).data + ) class UserSerializer(ModelSerializer): @@ -80,7 +101,14 @@ class UserSerializer(ModelSerializer): class Meta: model = User - fields = ('id', 'username', 'active_config', 'custom_connection_gateway', 'custom_connection_gateway_token', 'is_pro') + fields = ( + 'id', + 'username', + 'active_config', + 'custom_connection_gateway', + 'custom_connection_gateway_token', + 'is_pro', + ) read_only_fields = ('id', 'username') def get_is_pro(self, obj): @@ -121,8 +149,21 @@ class ChooseGatewayViewSet(RetrieveModelMixin, GenericViewSet): queryset = Gateway.objects.filter(enabled=True) serializer_class = GatewaySerializer + async def _authorize_client(self, gw): + c = GatewayAdminConnection(gw) + await c.connect() + token = await c.authorize_client() + await c.close() + return token + def get_object(self): gateways = list(self.queryset) if not len(gateways): raise NotFound() - return random.choice(gateways) + gw = random.choice(gateways) + + loop = asyncio.new_event_loop() + gw.auth_token = loop.run_until_complete(self._authorize_client(gw)) + loop.close() + + return gw diff --git a/tabby/app/consumers.py b/tabby/app/consumers.py index e9eee4b..89e1b67 100644 --- a/tabby/app/consumers.py +++ b/tabby/app/consumers.py @@ -1,11 +1,15 @@ import asyncio +import json import os +import secrets import ssl import websockets from channels.generic.websocket import AsyncWebsocketConsumer from django.conf import settings from urllib.parse import quote +from .models import Gateway + class GatewayConnection: _ssl_context: ssl.SSLContext = None @@ -42,6 +46,47 @@ class GatewayConnection: await self.context.__aexit__(None, None, None) +class GatewayAdminConnection: + _ssl_context: ssl.SSLContext = None + + def __init__(self, gateway: Gateway): + if not settings.CONNECTION_GATEWAY_AUTH_KEY: + raise RuntimeError('CONNECTION_GATEWAY_AUTH_KEY is required to manage connection gateways') + if not GatewayAdminConnection._ssl_context: + ctx = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + ctx.load_cert_chain( + os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_CERTIFICATE), + os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_KEY), + ) + if settings.CONNECTION_GATEWAY_AUTH_CA: + ctx.load_verify_locations( + cafile=os.path.realpath(settings.CONNECTION_GATEWAY_AUTH_CA), + ) + ctx.verify_mode = ssl.CERT_REQUIRED + GatewayAdminConnection._ssl_context = ctx + + self.url = f'wss://{gateway.host}:{gateway.admin_port}' + + async def connect(self): + self.context = websockets.connect(self.url, ssl=GatewayAdminConnection._ssl_context) + self.socket = await self.context.__aenter__() + + async def authorize_client(self) -> str: + token = secrets.token_hex(32) + await self.send(json.dumps({ + '_': 'authorize-client', + 'token': token, + })) + return token + + async def send(self, data): + await self.socket.send(data) + + async def close(self): + await self.socket.close() + await self.context.__aexit__(None, None, None) + + class TCPConsumer(AsyncWebsocketConsumer): async def connect(self): self.closed = False diff --git a/tabby/app/migrations/0003_auto_20210711_1855.py b/tabby/app/migrations/0003_auto_20210711_1855.py new file mode 100644 index 0000000..f34949d --- /dev/null +++ b/tabby/app/migrations/0003_auto_20210711_1855.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.3 on 2021-07-11 18:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0002_gateway'), + ] + + operations = [ + migrations.AddField( + model_name='gateway', + name='admin_port', + field=models.IntegerField(default=1235), + ), + migrations.AlterField( + model_name='user', + name='active_config', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='app.config'), + ), + ] diff --git a/tabby/app/models.py b/tabby/app/models.py index cddbbf9..01f877f 100644 --- a/tabby/app/models.py +++ b/tabby/app/models.py @@ -14,7 +14,7 @@ class Config(models.Model): class User(AbstractUser): - active_config = models.ForeignKey(Config, null=True, on_delete=models.CASCADE, related_name='+') + active_config = models.ForeignKey(Config, null=True, on_delete=models.SET_NULL, related_name='+') active_version = models.CharField(max_length=32, null=True) custom_connection_gateway = models.CharField(max_length=255, null=True) custom_connection_gateway_token = models.CharField(max_length=255, null=True) @@ -25,6 +25,7 @@ class User(AbstractUser): class Gateway(models.Model): host = models.CharField(max_length=255) port = models.IntegerField(default=1234) + admin_port = models.IntegerField(default=1235) enabled = models.BooleanField(default=True) secure = models.BooleanField(default=True)