This commit is contained in:
Eugene Pankov
2021-07-11 22:28:50 +02:00
parent da8a631a6d
commit 3704dcc483
9 changed files with 158 additions and 15 deletions

View File

@@ -21,6 +21,7 @@ export interface Config {
export interface Version { export interface Version {
version: string version: string
plugins: string[]
} }
export interface InstanceInfo { export interface InstanceInfo {
@@ -31,6 +32,7 @@ export interface Gateway {
host: string host: string
port: number port: number
url: string url: string
auth_token: string
} }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -31,7 +31,7 @@
.list-group.list-group-light .list-group.list-group-light
ng-container(*ngFor='let config of configService.configs') ng-container(*ngFor='let config of configService.configs')
button.list-group-item.list-group-item-action( button.list-group-item.list-group-item-action(
*ngIf='config !== configService.activeConfig', *ngIf='config.id !== configService.activeConfig?.id',
(click)='selectConfig(config)' (click)='selectConfig(config)'
) )
fa-icon([icon]='_configIcon') fa-icon([icon]='_configIcon')

View File

@@ -46,4 +46,11 @@ export class ConfigModalComponent {
this.modalInstance.dismiss() 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()
}
} }

View File

@@ -13,6 +13,7 @@ export class SocketProxy {
close$ = new Subject<Buffer>() close$ = new Subject<Buffer>()
url: string url: string
authToken: string
webSocket: WebSocket webSocket: WebSocket
initialBuffer: Buffer initialBuffer: Buffer
options: { options: {
@@ -27,15 +28,27 @@ export class SocketProxy {
async connect (options) { async connect (options) {
this.options = options this.options = options
this.url = this.appConnector.loginService.user.custom_connection_gateway this.url = this.appConnector.loginService.user.custom_connection_gateway
this.authToken = this.appConnector.loginService.user.custom_connection_gateway_token
if (!this.url) { if (!this.url) {
try { 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) { } catch (err) {
this.error$.next(err) this.error$.next(err)
return 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 => { this.webSocket.onmessage = async event => {
if (typeof(event.data) === 'string') { if (typeof(event.data) === 'string') {
this.handleServiceMessage(JSON.parse(event.data)) this.handleServiceMessage(JSON.parse(event.data))
@@ -53,7 +66,7 @@ export class SocketProxy {
this.sendServiceMessage({ this.sendServiceMessage({
_: 'hello', _: 'hello',
version: 1, version: 1,
auth_token: this.appConnector.loginService.user.custom_connection_gateway_token, auth_token: this.authToken,
}) })
} else if (msg._ === 'ready') { } else if (msg._ === 'ready') {
this.sendServiceMessage({ this.sendServiceMessage({
@@ -139,7 +152,7 @@ export class AppConnectorService {
} }
getPluginsToLoad (): string[] { getPluginsToLoad (): string[] {
return [ const loadOrder = [
'tabby-core', 'tabby-core',
'tabby-settings', 'tabby-settings',
'tabby-terminal', 'tabby-terminal',
@@ -147,6 +160,11 @@ export class AppConnectorService {
'tabby-community-color-schemes', 'tabby-community-color-schemes',
'tabby-web', 'tabby-web',
] ]
return [
...loadOrder.filter(x => this.version.plugins.includes(x)),
...this.version.plugins.filter(x => !loadOrder.includes(x)),
]
} }
createSocket () { createSocket () {

View File

@@ -76,6 +76,11 @@ export class ConfigService {
this.selectConfig(this.configs.find(c => c.id === this.loginService.user.active_config) ?? this.configs[0]) 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 () { private async init () {
this.configs = await this.http.get('/api/1/configs').toPromise() this.configs = await this.http.get('/api/1/configs').toPromise()
this.versions = await this.http.get('/api/1/versions').toPromise() this.versions = await this.http.get('/api/1/versions').toPromise()

View File

@@ -1,8 +1,10 @@
import os import asyncio
import random import random
from dataclasses import dataclass from tabby.app.consumers import GatewayAdminConnection
from django.conf import settings from django.conf import settings
from django.contrib.auth import logout from django.contrib.auth import logout
from dataclasses import dataclass
from pathlib import Path
from rest_framework import fields from rest_framework import fields
from rest_framework.exceptions import PermissionDenied, NotFound from rest_framework.exceptions import PermissionDenied, NotFound
from rest_framework.permissions import IsAuthenticated 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.viewsets import GenericViewSet, ModelViewSet
from rest_framework.serializers import ModelSerializer, Serializer from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework_dataclasses.serializers import DataclassSerializer from rest_framework_dataclasses.serializers import DataclassSerializer
from typing import List
from .models import Config, Gateway, User from .models import Config, Gateway, User
@@ -19,6 +22,7 @@ from .models import Config, Gateway, User
@dataclass @dataclass
class AppVersion: class AppVersion:
version: str version: str
plugins: List[str]
class AppVersionSerializer(DataclassSerializer): class AppVersionSerializer(DataclassSerializer):
@@ -28,6 +32,7 @@ class AppVersionSerializer(DataclassSerializer):
class GatewaySerializer(ModelSerializer): class GatewaySerializer(ModelSerializer):
url = fields.SerializerMethodField() url = fields.SerializerMethodField()
auth_token = fields.CharField()
class Meta: class Meta:
fields = '__all__' fields = '__all__'
@@ -65,13 +70,29 @@ class AppVersionViewSet(ListModelMixin, GenericViewSet):
queryset = '' queryset = ''
def _get_versions(self): 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): def list(self, request, *args, **kwargs):
return Response(self.serializer_class( return Response(
self._get_versions(), self.serializer_class(
many=True, self._get_versions(),
).data) many=True,
).data
)
class UserSerializer(ModelSerializer): class UserSerializer(ModelSerializer):
@@ -80,7 +101,14 @@ class UserSerializer(ModelSerializer):
class Meta: class Meta:
model = User 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') read_only_fields = ('id', 'username')
def get_is_pro(self, obj): def get_is_pro(self, obj):
@@ -121,8 +149,21 @@ class ChooseGatewayViewSet(RetrieveModelMixin, GenericViewSet):
queryset = Gateway.objects.filter(enabled=True) queryset = Gateway.objects.filter(enabled=True)
serializer_class = GatewaySerializer 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): def get_object(self):
gateways = list(self.queryset) gateways = list(self.queryset)
if not len(gateways): if not len(gateways):
raise NotFound() 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

View File

@@ -1,11 +1,15 @@
import asyncio import asyncio
import json
import os import os
import secrets
import ssl import ssl
import websockets import websockets
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings from django.conf import settings
from urllib.parse import quote from urllib.parse import quote
from .models import Gateway
class GatewayConnection: class GatewayConnection:
_ssl_context: ssl.SSLContext = None _ssl_context: ssl.SSLContext = None
@@ -42,6 +46,47 @@ class GatewayConnection:
await self.context.__aexit__(None, None, None) 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): class TCPConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
self.closed = False self.closed = False

View File

@@ -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'),
),
]

View File

@@ -14,7 +14,7 @@ class Config(models.Model):
class User(AbstractUser): 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) active_version = models.CharField(max_length=32, null=True)
custom_connection_gateway = models.CharField(max_length=255, null=True) custom_connection_gateway = models.CharField(max_length=255, null=True)
custom_connection_gateway_token = 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): class Gateway(models.Model):
host = models.CharField(max_length=255) host = models.CharField(max_length=255)
port = models.IntegerField(default=1234) port = models.IntegerField(default=1234)
admin_port = models.IntegerField(default=1235)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
secure = models.BooleanField(default=True) secure = models.BooleanField(default=True)