diff --git a/.dockerignore b/.dockerignore index 352faf761..8d20d66d6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,4 @@ blueprints/local .git !gen-ts-api/node_modules !gen-ts-api/dist/** +!gen-go-api/ diff --git a/.github/codespell-words.txt b/.github/codespell-words.txt index 71f2f1c2c..29fb24832 100644 --- a/.github/codespell-words.txt +++ b/.github/codespell-words.txt @@ -2,3 +2,4 @@ keypair keypairs hass warmup +ontext diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 282543d96..71bfc0d7a 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -249,12 +249,6 @@ jobs: VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Comment on PR - if: github.event_name == 'pull_request' - continue-on-error: true - uses: ./.github/actions/comment-pr-instructions - with: - tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} build-arm64: needs: ci-core-mark runs-on: ubuntu-latest @@ -303,3 +297,26 @@ jobs: platforms: linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max + pr-comment: + needs: + - build + - build-arm64 + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} + permissions: + # Needed to write comments on PRs + pull-requests: write + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: prepare variables + uses: ./.github/actions/docker-push-variables + id: ev + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + - name: Comment on PR + uses: ./.github/actions/comment-pr-instructions + with: + tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} diff --git a/.github/workflows/ci-outpost.yml b/.github/workflows/ci-outpost.yml index 196fa0b3b..35c83ac86 100644 --- a/.github/workflows/ci-outpost.yml +++ b/.github/workflows/ci-outpost.yml @@ -65,6 +65,7 @@ jobs: - proxy - ldap - radius + - rac runs-on: ubuntu-latest permissions: # Needed to upload contianer images to ghcr.io @@ -119,6 +120,7 @@ jobs: - proxy - ldap - radius + - rac goos: [linux] goarch: [amd64, arm64] steps: diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index c3c6a0d48..c002ab8a5 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -65,6 +65,7 @@ jobs: - proxy - ldap - radius + - rac steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 diff --git a/Makefile b/Makefile index a91ca7464..93092a779 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ test: ## Run the server tests and produce a coverage report (locally) lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors. isort $(PY_SOURCES) black $(PY_SOURCES) - ruff $(PY_SOURCES) + ruff --fix $(PY_SOURCES) codespell -w $(CODESPELL_ARGS) lint: ## Lint the python and golang sources diff --git a/authentik/core/channels.py b/authentik/core/channels.py index 00f213efc..722e9e03f 100644 --- a/authentik/core/channels.py +++ b/authentik/core/channels.py @@ -1,22 +1,29 @@ """Channels base classes""" +from channels.db import database_sync_to_async from channels.exceptions import DenyConnection -from channels.generic.websocket import JsonWebsocketConsumer from rest_framework.exceptions import AuthenticationFailed from structlog.stdlib import get_logger from authentik.api.authentication import bearer_auth -from authentik.core.models import User LOGGER = get_logger() -class AuthJsonConsumer(JsonWebsocketConsumer): +class TokenOutpostMiddleware: """Authorize a client with a token""" - user: User + def __init__(self, inner): + self.inner = inner - def connect(self): - headers = dict(self.scope["headers"]) + async def __call__(self, scope, receive, send): + scope = dict(scope) + await self.auth(scope) + return await self.inner(scope, receive, send) + + @database_sync_to_async + def auth(self, scope): + """Authenticate request from header""" + headers = dict(scope["headers"]) if b"authorization" not in headers: LOGGER.warning("WS Request without authorization header") raise DenyConnection() @@ -32,4 +39,4 @@ class AuthJsonConsumer(JsonWebsocketConsumer): LOGGER.warning("Failed to authenticate", exc=exc) raise DenyConnection() - self.user = user + scope["user"] = user diff --git a/authentik/core/views/interface.py b/authentik/core/views/interface.py index 82f09752c..c71bddbaf 100644 --- a/authentik/core/views/interface.py +++ b/authentik/core/views/interface.py @@ -22,6 +22,7 @@ class InterfaceView(TemplateView): kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}" kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" kwargs["build"] = get_build_hash() + kwargs["url_kwargs"] = self.kwargs return super().get_context_data(**kwargs) diff --git a/authentik/enterprise/policy.py b/authentik/enterprise/policy.py index 0c714322a..20bf438a0 100644 --- a/authentik/enterprise/policy.py +++ b/authentik/enterprise/policy.py @@ -1,6 +1,8 @@ """Enterprise license policies""" from typing import Optional +from django.utils.translation import gettext_lazy as _ + from authentik.core.models import User, UserTypes from authentik.enterprise.models import LicenseKey from authentik.policies.types import PolicyRequest, PolicyResult @@ -13,10 +15,10 @@ class EnterprisePolicyAccessView(PolicyAccessView): def check_license(self): """Check license""" if not LicenseKey.get_total().is_valid(): - return False + return PolicyResult(False, _("Enterprise required to access this feature.")) if self.request.user.type != UserTypes.INTERNAL: - return False - return True + return PolicyResult(False, _("Feature only accessible for internal users.")) + return PolicyResult(True) def user_has_access(self, user: Optional[User] = None) -> PolicyResult: user = user or self.request.user @@ -24,7 +26,7 @@ class EnterprisePolicyAccessView(PolicyAccessView): request.http_request = self.request result = super().user_has_access(user) enterprise_result = self.check_license() - if not enterprise_result: + if not enterprise_result.passing: return enterprise_result return result diff --git a/authentik/enterprise/providers/__init__.py b/authentik/enterprise/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/providers/rac/__init__.py b/authentik/enterprise/providers/rac/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/providers/rac/api/__init__.py b/authentik/enterprise/providers/rac/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/providers/rac/api/endpoints.py b/authentik/enterprise/providers/rac/api/endpoints.py new file mode 100644 index 000000000..b0b0239c5 --- /dev/null +++ b/authentik/enterprise/providers/rac/api/endpoints.py @@ -0,0 +1,133 @@ +"""RAC Provider API Views""" +from typing import Optional + +from django.core.cache import cache +from django.db.models import QuerySet +from django.urls import reverse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from rest_framework.fields import SerializerMethodField +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet +from structlog.stdlib import get_logger + +from authentik.core.api.used_by import UsedByMixin +from authentik.core.models import Provider +from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer +from authentik.enterprise.providers.rac.models import Endpoint +from authentik.policies.engine import PolicyEngine +from authentik.rbac.filters import ObjectFilter + +LOGGER = get_logger() + + +def user_endpoint_cache_key(user_pk: str) -> str: + """Cache key where endpoint list for user is saved""" + return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}" + + +class EndpointSerializer(ModelSerializer): + """Endpoint Serializer""" + + provider_obj = RACProviderSerializer(source="provider", read_only=True) + launch_url = SerializerMethodField() + + def get_launch_url(self, endpoint: Endpoint) -> Optional[str]: + """Build actual launch URL (the provider itself does not have one, just + individual endpoints)""" + try: + # pylint: disable=no-member + return reverse( + "authentik_providers_rac:start", + kwargs={"app": endpoint.provider.application.slug, "endpoint": endpoint.pk}, + ) + except Provider.application.RelatedObjectDoesNotExist: + return None + + class Meta: + model = Endpoint + fields = [ + "pk", + "name", + "provider", + "provider_obj", + "protocol", + "host", + "settings", + "property_mappings", + "auth_mode", + "launch_url", + ] + + +class EndpointViewSet(UsedByMixin, ModelViewSet): + """Endpoint Viewset""" + + queryset = Endpoint.objects.all() + serializer_class = EndpointSerializer + filterset_fields = ["name", "provider"] + search_fields = ["name", "protocol"] + ordering = ["name", "protocol"] + + def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: + """Custom filter_queryset method which ignores guardian, but still supports sorting""" + for backend in list(self.filter_backends): + if backend == ObjectFilter: + continue + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def _get_allowed_endpoints(self, queryset: QuerySet) -> list[Endpoint]: + endpoints = [] + for endpoint in queryset: + engine = PolicyEngine(endpoint, self.request.user, self.request) + engine.build() + if engine.passing: + endpoints.append(endpoint) + return endpoints + + @extend_schema( + parameters=[ + OpenApiParameter( + "search", + OpenApiTypes.STR, + ), + OpenApiParameter( + name="superuser_full_list", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + ), + ], + responses={ + 200: EndpointSerializer(many=True), + 400: OpenApiResponse(description="Bad request"), + }, + ) + def list(self, request: Request, *args, **kwargs) -> Response: + """List accessible endpoints""" + should_cache = request.GET.get("search", "") == "" + + superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true" + if superuser_full_list and request.user.is_superuser: + return super().list(request) + + queryset = self._filter_queryset_for_list(self.get_queryset()) + self.paginate_queryset(queryset) + + allowed_endpoints = [] + if not should_cache: + allowed_endpoints = self._get_allowed_endpoints(queryset) + if should_cache: + allowed_endpoints = cache.get(user_endpoint_cache_key(self.request.user.pk)) + if not allowed_endpoints: + LOGGER.debug("Caching allowed endpoint list") + allowed_endpoints = self._get_allowed_endpoints(queryset) + cache.set( + user_endpoint_cache_key(self.request.user.pk), + allowed_endpoints, + timeout=86400, + ) + serializer = self.get_serializer(allowed_endpoints, many=True) + return self.get_paginated_response(serializer.data) diff --git a/authentik/enterprise/providers/rac/api/property_mappings.py b/authentik/enterprise/providers/rac/api/property_mappings.py new file mode 100644 index 000000000..35daec95c --- /dev/null +++ b/authentik/enterprise/providers/rac/api/property_mappings.py @@ -0,0 +1,35 @@ +"""RAC Provider API Views""" +from rest_framework.fields import CharField +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.propertymappings import PropertyMappingSerializer +from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import JSONDictField +from authentik.enterprise.providers.rac.models import RACPropertyMapping + + +class RACPropertyMappingSerializer(PropertyMappingSerializer): + """RACPropertyMapping Serializer""" + + static_settings = JSONDictField() + expression = CharField(allow_blank=True, required=False) + + def validate_expression(self, expression: str) -> str: + """Test Syntax""" + if expression == "": + return expression + return super().validate_expression(expression) + + class Meta: + model = RACPropertyMapping + fields = PropertyMappingSerializer.Meta.fields + ["static_settings"] + + +class RACPropertyMappingViewSet(UsedByMixin, ModelViewSet): + """RACPropertyMapping Viewset""" + + queryset = RACPropertyMapping.objects.all() + serializer_class = RACPropertyMappingSerializer + search_fields = ["name"] + ordering = ["name"] + filterset_fields = ["name", "managed"] diff --git a/authentik/enterprise/providers/rac/api/providers.py b/authentik/enterprise/providers/rac/api/providers.py new file mode 100644 index 000000000..6dd4f9f82 --- /dev/null +++ b/authentik/enterprise/providers/rac/api/providers.py @@ -0,0 +1,31 @@ +"""RAC Provider API Views""" +from rest_framework.fields import CharField, ListField +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.providers import ProviderSerializer +from authentik.core.api.used_by import UsedByMixin +from authentik.enterprise.providers.rac.models import RACProvider + + +class RACProviderSerializer(ProviderSerializer): + """RACProvider Serializer""" + + outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all") + + class Meta: + model = RACProvider + fields = ProviderSerializer.Meta.fields + ["settings", "outpost_set", "connection_expiry"] + extra_kwargs = ProviderSerializer.Meta.extra_kwargs + + +class RACProviderViewSet(UsedByMixin, ModelViewSet): + """RACProvider Viewset""" + + queryset = RACProvider.objects.all() + serializer_class = RACProviderSerializer + filterset_fields = { + "application": ["isnull"], + "name": ["iexact"], + } + search_fields = ["name"] + ordering = ["name"] diff --git a/authentik/enterprise/providers/rac/apps.py b/authentik/enterprise/providers/rac/apps.py new file mode 100644 index 000000000..973159bb9 --- /dev/null +++ b/authentik/enterprise/providers/rac/apps.py @@ -0,0 +1,17 @@ +"""RAC app config""" +from authentik.blueprints.apps import ManagedAppConfig + + +class AuthentikEnterpriseProviderRAC(ManagedAppConfig): + """authentik enterprise rac app config""" + + name = "authentik.enterprise.providers.rac" + label = "authentik_providers_rac" + verbose_name = "authentik Enterprise.Providers.RAC" + default = True + mountpoint = "" + ws_mountpoint = "authentik.enterprise.providers.rac.urls" + + def reconcile_load_rac_signals(self): + """Load rac signals""" + self.import_module("authentik.enterprise.providers.rac.signals") diff --git a/authentik/enterprise/providers/rac/consumer_client.py b/authentik/enterprise/providers/rac/consumer_client.py new file mode 100644 index 000000000..57fef7d74 --- /dev/null +++ b/authentik/enterprise/providers/rac/consumer_client.py @@ -0,0 +1,163 @@ +"""RAC Client consumer""" +from asgiref.sync import async_to_sync +from channels.db import database_sync_to_async +from channels.exceptions import ChannelFull, DenyConnection +from channels.generic.websocket import AsyncWebsocketConsumer +from django.http.request import QueryDict +from structlog.stdlib import BoundLogger, get_logger + +from authentik.enterprise.providers.rac.models import ConnectionToken, RACProvider +from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE +from authentik.outposts.models import Outpost, OutpostState, OutpostType + +# Global broadcast group, which messages are sent to when the outpost connects back +# to authentik for a specific connection +# The `RACClientConsumer` consumer adds itself to this group on connection, +# and removes itself once it has been assigned a specific outpost channel +RAC_CLIENT_GROUP = "group_enterprise_rac_client" +# A group for all connections in a given authentik session ID +# A disconnect message is sent to this group when the session expires/is deleted +RAC_CLIENT_GROUP_SESSION = "group_enterprise_rac_client_%(session)s" +# A group for all connections with a specific token, which in almost all cases +# is just one connection, however this is used to disconnect the connection +# when the token is deleted +RAC_CLIENT_GROUP_TOKEN = "group_enterprise_rac_token_%(token)s" # nosec + +# Step 1: Client connects to this websocket endpoint +# Step 2: We prepare all the connection args for Guac +# Step 3: Send a websocket message to a single outpost that has this provider assigned +# (Currently sending to all of them) +# (Should probably do different load balancing algorithms) +# Step 4: Outpost creates a websocket connection back to authentik +# with /ws/outpost_rac// +# Step 5: This consumer transfers data between the two channels + + +class RACClientConsumer(AsyncWebsocketConsumer): + """RAC client consumer the browser connects to""" + + dest_channel_id: str = "" + provider: RACProvider + token: ConnectionToken + logger: BoundLogger + + async def connect(self): + await self.accept("guacamole") + await self.channel_layer.group_add(RAC_CLIENT_GROUP, self.channel_name) + await self.channel_layer.group_add( + RAC_CLIENT_GROUP_SESSION % {"session": self.scope["session"].session_key}, + self.channel_name, + ) + await self.init_outpost_connection() + + async def disconnect(self, code): + self.logger.debug("Disconnecting") + # Tell the outpost we're disconnecting + await self.channel_layer.send( + self.dest_channel_id, + { + "type": "event.disconnect", + }, + ) + + @database_sync_to_async + def init_outpost_connection(self): + """Initialize guac connection settings""" + self.token = ConnectionToken.filter_not_expired( + token=self.scope["url_route"]["kwargs"]["token"] + ).first() + if not self.token: + raise DenyConnection() + self.provider = self.token.provider + params = self.token.get_settings() + self.logger = get_logger().bind( + endpoint=self.token.endpoint.name, user=self.scope["user"].username + ) + msg = { + "type": "event.provider.specific", + "sub_type": "init_connection", + "dest_channel_id": self.channel_name, + "params": params, + "protocol": self.token.endpoint.protocol, + } + query = QueryDict(self.scope["query_string"].decode()) + for key in ["screen_width", "screen_height", "screen_dpi", "audio"]: + value = query.get(key, None) + if not value: + continue + msg[key] = str(value) + outposts = Outpost.objects.filter( + type=OutpostType.RAC, + providers__in=[self.provider], + ) + if not outposts.exists(): + self.logger.warning("Provider has no outpost") + raise DenyConnection() + for outpost in outposts: + # Sort all states for the outpost by connection count + states = sorted( + OutpostState.for_outpost(outpost), + key=lambda state: int(state.args.get("active_connections", 0)), + ) + if len(states) < 1: + continue + self.logger.debug("Sending out connection broadcast") + async_to_sync(self.channel_layer.group_send)( + OUTPOST_GROUP_INSTANCE % {"outpost_pk": str(outpost.pk), "instance": states[0].uid}, + msg, + ) + + async def receive(self, text_data=None, bytes_data=None): + """Mirror data received from client to the dest_channel_id + which is the channel talking to guacd""" + if self.dest_channel_id == "": + return + if self.token.is_expired: + await self.event_disconnect({"reason": "token_expiry"}) + return + try: + await self.channel_layer.send( + self.dest_channel_id, + { + "type": "event.send", + "text_data": text_data, + "bytes_data": bytes_data, + }, + ) + except ChannelFull: + pass + + async def event_outpost_connected(self, event: dict): + """Handle event broadcasted from outpost consumer, and check if they + created a connection for us""" + outpost_channel = event.get("outpost_channel") + if event.get("client_channel") != self.channel_name: + return + if self.dest_channel_id != "": + # We've already selected an outpost channel, so tell the other channel to disconnect + # This should never happen since we remove ourselves from the broadcast group + await self.channel_layer.send( + outpost_channel, + { + "type": "event.disconnect", + }, + ) + return + self.logger.debug("Connected to a single outpost instance") + self.dest_channel_id = outpost_channel + # Since we have a specific outpost channel now, we can remove + # ourselves from the global broadcast group + await self.channel_layer.group_discard(RAC_CLIENT_GROUP, self.channel_name) + + async def event_send(self, event: dict): + """Handler called by outpost websocket that sends data to this specific + client connection""" + if self.token.is_expired: + await self.event_disconnect({"reason": "token_expiry"}) + return + await self.send(text_data=event.get("text_data"), bytes_data=event.get("bytes_data")) + + async def event_disconnect(self, event: dict): + """Disconnect when the session ends""" + self.logger.info("Disconnecting RAC connection", reason=event.get("reason")) + await self.close() diff --git a/authentik/enterprise/providers/rac/consumer_outpost.py b/authentik/enterprise/providers/rac/consumer_outpost.py new file mode 100644 index 000000000..8fa42d859 --- /dev/null +++ b/authentik/enterprise/providers/rac/consumer_outpost.py @@ -0,0 +1,48 @@ +"""RAC consumer""" +from channels.exceptions import ChannelFull +from channels.generic.websocket import AsyncWebsocketConsumer + +from authentik.enterprise.providers.rac.consumer_client import RAC_CLIENT_GROUP + + +class RACOutpostConsumer(AsyncWebsocketConsumer): + """Consumer the outpost connects to, to send specific data back to a client connection""" + + dest_channel_id: str + + async def connect(self): + self.dest_channel_id = self.scope["url_route"]["kwargs"]["channel"] + await self.accept() + await self.channel_layer.group_send( + RAC_CLIENT_GROUP, + { + "type": "event.outpost.connected", + "outpost_channel": self.channel_name, + "client_channel": self.dest_channel_id, + }, + ) + + async def receive(self, text_data=None, bytes_data=None): + """Mirror data received from guacd running in the outpost + to the dest_channel_id which is the channel talking to the browser""" + try: + await self.channel_layer.send( + self.dest_channel_id, + { + "type": "event.send", + "text_data": text_data, + "bytes_data": bytes_data, + }, + ) + except ChannelFull: + pass + + async def event_send(self, event: dict): + """Handler called by client websocket that sends data to this specific + outpost connection""" + await self.send(text_data=event.get("text_data"), bytes_data=event.get("bytes_data")) + + async def event_disconnect(self, event: dict): + """Tell outpost we're about to disconnect""" + await self.send(text_data="0.authentik.disconnect") + await self.close() diff --git a/authentik/enterprise/providers/rac/controllers/__init__.py b/authentik/enterprise/providers/rac/controllers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/providers/rac/controllers/docker.py b/authentik/enterprise/providers/rac/controllers/docker.py new file mode 100644 index 000000000..8dac04d06 --- /dev/null +++ b/authentik/enterprise/providers/rac/controllers/docker.py @@ -0,0 +1,11 @@ +"""RAC Provider Docker Controller""" +from authentik.outposts.controllers.docker import DockerController +from authentik.outposts.models import DockerServiceConnection, Outpost + + +class RACDockerController(DockerController): + """RAC Provider Docker Controller""" + + def __init__(self, outpost: Outpost, connection: DockerServiceConnection): + super().__init__(outpost, connection) + self.deployment_ports = [] diff --git a/authentik/enterprise/providers/rac/controllers/kubernetes.py b/authentik/enterprise/providers/rac/controllers/kubernetes.py new file mode 100644 index 000000000..f7768735e --- /dev/null +++ b/authentik/enterprise/providers/rac/controllers/kubernetes.py @@ -0,0 +1,13 @@ +"""RAC Provider Kubernetes Controller""" +from authentik.outposts.controllers.k8s.service import ServiceReconciler +from authentik.outposts.controllers.kubernetes import KubernetesController +from authentik.outposts.models import KubernetesServiceConnection, Outpost + + +class RACKubernetesController(KubernetesController): + """RAC Provider Kubernetes Controller""" + + def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): + super().__init__(outpost, connection) + self.deployment_ports = [] + del self.reconcilers[ServiceReconciler.reconciler_name()] diff --git a/authentik/enterprise/providers/rac/migrations/0001_initial.py b/authentik/enterprise/providers/rac/migrations/0001_initial.py new file mode 100644 index 000000000..ef8702886 --- /dev/null +++ b/authentik/enterprise/providers/rac/migrations/0001_initial.py @@ -0,0 +1,164 @@ +# Generated by Django 4.2.8 on 2023-12-29 15:58 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import authentik.core.models +import authentik.lib.utils.time + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("authentik_policies", "0011_policybinding_failure_result_and_more"), + ("authentik_core", "0032_group_roles"), + ] + + operations = [ + migrations.CreateModel( + name="RACPropertyMapping", + fields=[ + ( + "propertymapping_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.propertymapping", + ), + ), + ("static_settings", models.JSONField(default=dict)), + ], + options={ + "verbose_name": "RAC Property Mapping", + "verbose_name_plural": "RAC Property Mappings", + }, + bases=("authentik_core.propertymapping",), + ), + migrations.CreateModel( + name="RACProvider", + fields=[ + ( + "provider_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.provider", + ), + ), + ("settings", models.JSONField(default=dict)), + ( + "auth_mode", + models.TextField( + choices=[("static", "Static"), ("prompt", "Prompt")], default="prompt" + ), + ), + ( + "connection_expiry", + models.TextField( + default="hours=8", + help_text="Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + ], + options={ + "verbose_name": "RAC Provider", + "verbose_name_plural": "RAC Providers", + }, + bases=("authentik_core.provider",), + ), + migrations.CreateModel( + name="Endpoint", + fields=[ + ( + "policybindingmodel_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.policybindingmodel", + ), + ), + ("name", models.TextField()), + ("host", models.TextField()), + ( + "protocol", + models.TextField(choices=[("rdp", "Rdp"), ("vnc", "Vnc"), ("ssh", "Ssh")]), + ), + ("settings", models.JSONField(default=dict)), + ( + "auth_mode", + models.TextField(choices=[("static", "Static"), ("prompt", "Prompt")]), + ), + ( + "property_mappings", + models.ManyToManyField( + blank=True, default=None, to="authentik_core.propertymapping" + ), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_rac.racprovider", + ), + ), + ], + options={ + "verbose_name": "RAC Endpoint", + "verbose_name_plural": "RAC Endpoints", + }, + bases=("authentik_policies.policybindingmodel", models.Model), + ), + migrations.CreateModel( + name="ConnectionToken", + fields=[ + ( + "expires", + models.DateTimeField(default=authentik.core.models.default_token_duration), + ), + ("expiring", models.BooleanField(default=True)), + ( + "connection_token_uuid", + models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), + ), + ("token", models.TextField(default=authentik.core.models.default_token_key)), + ("settings", models.JSONField(default=dict)), + ( + "endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_rac.endpoint", + ), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_rac.racprovider", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_core.authenticatedsession", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/authentik/enterprise/providers/rac/migrations/__init__.py b/authentik/enterprise/providers/rac/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/providers/rac/models.py b/authentik/enterprise/providers/rac/models.py new file mode 100644 index 000000000..d79bbd54c --- /dev/null +++ b/authentik/enterprise/providers/rac/models.py @@ -0,0 +1,191 @@ +"""RAC Models""" +from typing import Optional +from uuid import uuid4 + +from deepmerge import always_merger +from django.db import models +from django.db.models import QuerySet +from django.utils.translation import gettext as _ +from rest_framework.serializers import Serializer +from structlog.stdlib import get_logger + +from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.models import ExpiringModel, PropertyMapping, Provider, default_token_key +from authentik.events.models import Event, EventAction +from authentik.lib.models import SerializerModel +from authentik.lib.utils.time import timedelta_string_validator +from authentik.policies.models import PolicyBindingModel + +LOGGER = get_logger() + + +class Protocols(models.TextChoices): + """Supported protocols""" + + RDP = "rdp" + VNC = "vnc" + SSH = "ssh" + + +class AuthenticationMode(models.TextChoices): + """Authentication modes""" + + STATIC = "static" + PROMPT = "prompt" + + +class RACProvider(Provider): + """Remotely access computers/servers""" + + settings = models.JSONField(default=dict) + auth_mode = models.TextField( + choices=AuthenticationMode.choices, default=AuthenticationMode.PROMPT + ) + connection_expiry = models.TextField( + default="hours=8", + validators=[timedelta_string_validator], + help_text=_( + "Determines how long a session lasts. Default of 0 means " + "that the sessions lasts until the browser is closed. " + "(Format: hours=-1;minutes=-2;seconds=-3)" + ), + ) + + @property + def launch_url(self) -> Optional[str]: + """URL to this provider and initiate authorization for the user. + Can return None for providers that are not URL-based""" + return "goauthentik.io://providers/rac/launch" + + @property + def component(self) -> str: + return "ak-provider-rac-form" + + @property + def serializer(self) -> type[Serializer]: + from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer + + return RACProviderSerializer + + class Meta: + verbose_name = _("RAC Provider") + verbose_name_plural = _("RAC Providers") + + +class Endpoint(SerializerModel, PolicyBindingModel): + """Remote-accessible endpoint""" + + name = models.TextField() + host = models.TextField() + protocol = models.TextField(choices=Protocols.choices) + settings = models.JSONField(default=dict) + auth_mode = models.TextField(choices=AuthenticationMode.choices) + provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE) + + property_mappings = models.ManyToManyField( + "authentik_core.PropertyMapping", default=None, blank=True + ) + + @property + def serializer(self) -> type[Serializer]: + from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer + + return EndpointSerializer + + def __str__(self): + return f"RAC Endpoint {self.name}" + + class Meta: + verbose_name = _("RAC Endpoint") + verbose_name_plural = _("RAC Endpoints") + + +class RACPropertyMapping(PropertyMapping): + """Configure settings for remote access endpoints.""" + + static_settings = models.JSONField(default=dict) + + @property + def component(self) -> str: + return "ak-property-mapping-rac-form" + + @property + def serializer(self) -> type[Serializer]: + from authentik.enterprise.providers.rac.api.property_mappings import ( + RACPropertyMappingSerializer, + ) + + return RACPropertyMappingSerializer + + class Meta: + verbose_name = _("RAC Property Mapping") + verbose_name_plural = _("RAC Property Mappings") + + +class ConnectionToken(ExpiringModel): + """Token for a single connection to a specified endpoint""" + + connection_token_uuid = models.UUIDField(default=uuid4, primary_key=True) + provider = models.ForeignKey(RACProvider, on_delete=models.CASCADE) + endpoint = models.ForeignKey(Endpoint, on_delete=models.CASCADE) + token = models.TextField(default=default_token_key) + settings = models.JSONField(default=dict) + session = models.ForeignKey("authentik_core.AuthenticatedSession", on_delete=models.CASCADE) + + def get_settings(self) -> dict: + """Get settings""" + default_settings = {} + if ":" in self.endpoint.host: + host, _, port = self.endpoint.host.partition(":") + default_settings["hostname"] = host + default_settings["port"] = str(port) + else: + default_settings["hostname"] = self.endpoint.host + default_settings["client-name"] = "authentik" + # default_settings["enable-drive"] = "true" + # default_settings["drive-name"] = "authentik" + settings = {} + always_merger.merge(settings, default_settings) + always_merger.merge(settings, self.endpoint.provider.settings) + always_merger.merge(settings, self.endpoint.settings) + always_merger.merge(settings, self.settings) + + def mapping_evaluator(mappings: QuerySet): + for mapping in mappings: + mapping: RACPropertyMapping + if len(mapping.static_settings) > 0: + always_merger.merge(settings, mapping.static_settings) + continue + try: + mapping_settings = mapping.evaluate( + self.session.user, None, endpoint=self.endpoint, provider=self.provider + ) + always_merger.merge(settings, mapping_settings) + except PropertyMappingExpressionException as exc: + Event.new( + EventAction.CONFIGURATION_ERROR, + message=f"Failed to evaluate property-mapping: '{mapping.name}'", + provider=self.provider, + mapping=mapping, + ).set_user(self.session.user).save() + LOGGER.warning("Failed to evaluate property mapping", exc=exc) + + mapping_evaluator( + RACPropertyMapping.objects.filter(provider__in=[self.provider]).order_by("name") + ) + mapping_evaluator( + RACPropertyMapping.objects.filter(endpoint__in=[self.endpoint]).order_by("name") + ) + + settings["drive-path"] = f"/tmp/connection/{self.token}" # nosec + settings["create-drive-path"] = "true" + # Ensure all values of the settings dict are strings + for key, value in settings.items(): + if isinstance(value, str): + continue + # Special case for bools + if isinstance(value, bool): + settings[key] = str(value).lower() + continue + settings[key] = str(value) + return settings diff --git a/authentik/enterprise/providers/rac/signals.py b/authentik/enterprise/providers/rac/signals.py new file mode 100644 index 000000000..21f727690 --- /dev/null +++ b/authentik/enterprise/providers/rac/signals.py @@ -0,0 +1,54 @@ +"""RAC Signals""" +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.contrib.auth.signals import user_logged_out +from django.core.cache import cache +from django.db.models import Model +from django.db.models.signals import post_save, pre_delete +from django.dispatch import receiver +from django.http import HttpRequest + +from authentik.core.models import User +from authentik.enterprise.providers.rac.api.endpoints import user_endpoint_cache_key +from authentik.enterprise.providers.rac.consumer_client import ( + RAC_CLIENT_GROUP_SESSION, + RAC_CLIENT_GROUP_TOKEN, +) +from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint + + +@receiver(user_logged_out) +def user_logged_out_session(sender, request: HttpRequest, user: User, **_): + """Disconnect any open RAC connections""" + layer = get_channel_layer() + async_to_sync(layer.group_send)( + RAC_CLIENT_GROUP_SESSION + % { + "session": request.session.session_key, + }, + {"type": "event.disconnect", "reason": "session_logout"}, + ) + + +@receiver(pre_delete, sender=ConnectionToken) +def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_): + """Disconnect session when connection token is deleted""" + layer = get_channel_layer() + async_to_sync(layer.group_send)( + RAC_CLIENT_GROUP_TOKEN + % { + "token": instance.token, + }, + {"type": "event.disconnect", "reason": "token_delete"}, + ) + + +@receiver(post_save, sender=Endpoint) +def post_save_application(sender: type[Model], instance, created: bool, **_): + """Clear user's application cache upon application creation""" + if not created: # pragma: no cover + return + + # Delete user endpoint cache + keys = cache.keys(user_endpoint_cache_key("*")) + cache.delete_many(keys) diff --git a/authentik/enterprise/providers/rac/templates/if/rac.html b/authentik/enterprise/providers/rac/templates/if/rac.html new file mode 100644 index 000000000..1d1a03398 --- /dev/null +++ b/authentik/enterprise/providers/rac/templates/if/rac.html @@ -0,0 +1,18 @@ +{% extends "base/skeleton.html" %} + +{% load static %} + +{% block head %} + + + + + +{% include "base/header_js.html" %} +{% endblock %} + +{% block body %} + + + +{% endblock %} diff --git a/authentik/enterprise/providers/rac/tests/__init__.py b/authentik/enterprise/providers/rac/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py new file mode 100644 index 000000000..0a659bccd --- /dev/null +++ b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py @@ -0,0 +1,168 @@ +"""Test Endpoints API""" + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_admin_user +from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider +from authentik.lib.generators import generate_id +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.models import PolicyBinding + + +class TestEndpointsAPI(APITestCase): + """Test endpoints API""" + + def setUp(self) -> None: + self.user = create_test_admin_user() + self.provider = RACProvider.objects.create( + name=generate_id(), + ) + self.app = Application.objects.create( + name=generate_id(), + slug=generate_id(), + provider=self.provider, + ) + self.allowed = Endpoint.objects.create( + name=f"a-{generate_id()}", + host=generate_id(), + protocol=Protocols.RDP, + provider=self.provider, + ) + self.denied = Endpoint.objects.create( + name=f"b-{generate_id()}", + host=generate_id(), + protocol=Protocols.RDP, + provider=self.provider, + ) + PolicyBinding.objects.create( + target=self.denied, + policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), + order=0, + ) + + def test_list(self): + """Test list operation without superuser_full_list""" + self.client.force_login(self.user) + response = self.client.get(reverse("authentik_api:endpoint-list")) + self.assertJSONEqual( + response.content.decode(), + { + "pagination": { + "next": 0, + "previous": 0, + "count": 2, + "current": 1, + "total_pages": 1, + "start_index": 1, + "end_index": 2, + }, + "results": [ + { + "pk": str(self.allowed.pk), + "name": self.allowed.name, + "provider": self.provider.pk, + "provider_obj": { + "pk": self.provider.pk, + "name": self.provider.name, + "authentication_flow": None, + "authorization_flow": None, + "property_mappings": [], + "connection_expiry": "hours=8", + "component": "ak-provider-rac-form", + "assigned_application_slug": self.app.slug, + "assigned_application_name": self.app.name, + "verbose_name": "RAC Provider", + "verbose_name_plural": "RAC Providers", + "meta_model_name": "authentik_providers_rac.racprovider", + "settings": {}, + "outpost_set": [], + }, + "protocol": "rdp", + "host": self.allowed.host, + "settings": {}, + "property_mappings": [], + "auth_mode": "", + "launch_url": f"/application/rac/{self.app.slug}/{str(self.allowed.pk)}/", + }, + ], + }, + ) + + def test_list_superuser_full_list(self): + """Test list operation with superuser_full_list""" + self.client.force_login(self.user) + response = self.client.get( + reverse("authentik_api:endpoint-list") + "?superuser_full_list=true" + ) + self.assertJSONEqual( + response.content.decode(), + { + "pagination": { + "next": 0, + "previous": 0, + "count": 2, + "current": 1, + "total_pages": 1, + "start_index": 1, + "end_index": 2, + }, + "results": [ + { + "pk": str(self.allowed.pk), + "name": self.allowed.name, + "provider": self.provider.pk, + "provider_obj": { + "pk": self.provider.pk, + "name": self.provider.name, + "authentication_flow": None, + "authorization_flow": None, + "property_mappings": [], + "component": "ak-provider-rac-form", + "assigned_application_slug": self.app.slug, + "assigned_application_name": self.app.name, + "connection_expiry": "hours=8", + "verbose_name": "RAC Provider", + "verbose_name_plural": "RAC Providers", + "meta_model_name": "authentik_providers_rac.racprovider", + "settings": {}, + "outpost_set": [], + }, + "protocol": "rdp", + "host": self.allowed.host, + "settings": {}, + "property_mappings": [], + "auth_mode": "", + "launch_url": f"/application/rac/{self.app.slug}/{str(self.allowed.pk)}/", + }, + { + "pk": str(self.denied.pk), + "name": self.denied.name, + "provider": self.provider.pk, + "provider_obj": { + "pk": self.provider.pk, + "name": self.provider.name, + "authentication_flow": None, + "authorization_flow": None, + "property_mappings": [], + "component": "ak-provider-rac-form", + "assigned_application_slug": self.app.slug, + "assigned_application_name": self.app.name, + "connection_expiry": "hours=8", + "verbose_name": "RAC Provider", + "verbose_name_plural": "RAC Providers", + "meta_model_name": "authentik_providers_rac.racprovider", + "settings": {}, + "outpost_set": [], + }, + "protocol": "rdp", + "host": self.denied.host, + "settings": {}, + "property_mappings": [], + "auth_mode": "", + "launch_url": f"/application/rac/{self.app.slug}/{str(self.denied.pk)}/", + }, + ], + }, + ) diff --git a/authentik/enterprise/providers/rac/tests/test_models.py b/authentik/enterprise/providers/rac/tests/test_models.py new file mode 100644 index 000000000..48218f41b --- /dev/null +++ b/authentik/enterprise/providers/rac/tests/test_models.py @@ -0,0 +1,144 @@ +"""Test RAC Models""" +from django.test import TransactionTestCase + +from authentik.core.models import Application, AuthenticatedSession +from authentik.core.tests.utils import create_test_admin_user +from authentik.enterprise.providers.rac.models import ( + ConnectionToken, + Endpoint, + Protocols, + RACPropertyMapping, + RACProvider, +) +from authentik.lib.generators import generate_id + + +class TestModels(TransactionTestCase): + """Test RAC Models""" + + def setUp(self): + self.user = create_test_admin_user() + self.provider = RACProvider.objects.create( + name=generate_id(), + ) + self.app = Application.objects.create( + name=generate_id(), + slug=generate_id(), + provider=self.provider, + ) + self.endpoint = Endpoint.objects.create( + name=generate_id(), + host=f"{generate_id()}:1324", + protocol=Protocols.RDP, + provider=self.provider, + ) + + def test_settings_merge(self): + """Test settings merge""" + token = ConnectionToken.objects.create( + provider=self.provider, + endpoint=self.endpoint, + session=AuthenticatedSession.objects.create( + user=self.user, + session_key=generate_id(), + ), + ) + path = f"/tmp/connection/{token.token}" # nosec + self.assertEqual( + token.get_settings(), + { + "hostname": self.endpoint.host.split(":")[0], + "port": "1324", + "client-name": "authentik", + "drive-path": path, + "create-drive-path": "true", + }, + ) + # Set settings in provider + self.provider.settings = {"level": "provider"} + self.provider.save() + self.assertEqual( + token.get_settings(), + { + "hostname": self.endpoint.host.split(":")[0], + "port": "1324", + "client-name": "authentik", + "drive-path": path, + "create-drive-path": "true", + "level": "provider", + }, + ) + # Set settings in endpoint + self.endpoint.settings = { + "level": "endpoint", + } + self.endpoint.save() + self.assertEqual( + token.get_settings(), + { + "hostname": self.endpoint.host.split(":")[0], + "port": "1324", + "client-name": "authentik", + "drive-path": path, + "create-drive-path": "true", + "level": "endpoint", + }, + ) + # Set settings in token + token.settings = { + "level": "token", + } + token.save() + self.assertEqual( + token.get_settings(), + { + "hostname": self.endpoint.host.split(":")[0], + "port": "1324", + "client-name": "authentik", + "drive-path": path, + "create-drive-path": "true", + "level": "token", + }, + ) + # Set settings in property mapping (provider) + mapping = RACPropertyMapping.objects.create( + name=generate_id(), + expression="""return { + "level": "property_mapping_provider" + }""", + ) + self.provider.property_mappings.add(mapping) + self.assertEqual( + token.get_settings(), + { + "hostname": self.endpoint.host.split(":")[0], + "port": "1324", + "client-name": "authentik", + "drive-path": path, + "create-drive-path": "true", + "level": "property_mapping_provider", + }, + ) + # Set settings in property mapping (endpoint) + mapping = RACPropertyMapping.objects.create( + name=generate_id(), + static_settings={ + "level": "property_mapping_endpoint", + "foo": True, + "bar": 6, + }, + ) + self.endpoint.property_mappings.add(mapping) + self.assertEqual( + token.get_settings(), + { + "hostname": self.endpoint.host.split(":")[0], + "port": "1324", + "client-name": "authentik", + "drive-path": path, + "create-drive-path": "true", + "level": "property_mapping_endpoint", + "foo": "true", + "bar": "6", + }, + ) diff --git a/authentik/enterprise/providers/rac/tests/test_views.py b/authentik/enterprise/providers/rac/tests/test_views.py new file mode 100644 index 000000000..e2fb14a11 --- /dev/null +++ b/authentik/enterprise/providers/rac/tests/test_views.py @@ -0,0 +1,132 @@ +"""RAC Views tests""" +from datetime import timedelta +from json import loads +from time import mktime +from unittest.mock import MagicMock, patch + +from django.urls import reverse +from django.utils.timezone import now +from rest_framework.test import APITestCase + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_admin_user, create_test_flow +from authentik.enterprise.models import License, LicenseKey +from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider +from authentik.lib.generators import generate_id +from authentik.policies.denied import AccessDeniedResponse +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.models import PolicyBinding + + +class TestRACViews(APITestCase): + """RAC Views tests""" + + def setUp(self): + self.user = create_test_admin_user() + self.flow = create_test_flow() + self.provider = RACProvider.objects.create(name=generate_id(), authorization_flow=self.flow) + self.app = Application.objects.create( + name=generate_id(), + slug=generate_id(), + provider=self.provider, + ) + self.endpoint = Endpoint.objects.create( + name=generate_id(), + host=f"{generate_id()}:1324", + protocol=Protocols.RDP, + provider=self.provider, + ) + + @patch( + "authentik.enterprise.models.LicenseKey.validate", + MagicMock( + return_value=LicenseKey( + aud="", + exp=int(mktime((now() + timedelta(days=3000)).timetuple())), + name=generate_id(), + internal_users=100, + external_users=100, + ) + ), + ) + def test_no_policy(self): + """Test request""" + License.objects.create(key=generate_id()) + self.client.force_login(self.user) + response = self.client.get( + reverse( + "authentik_providers_rac:start", + kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)}, + ) + ) + self.assertEqual(response.status_code, 302) + flow_response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + body = loads(flow_response.content) + next_url = body["to"] + final_response = self.client.get(next_url) + self.assertEqual(final_response.status_code, 200) + + @patch( + "authentik.enterprise.models.LicenseKey.validate", + MagicMock( + return_value=LicenseKey( + aud="", + exp=int(mktime((now() + timedelta(days=3000)).timetuple())), + name=generate_id(), + internal_users=100, + external_users=100, + ) + ), + ) + def test_app_deny(self): + """Test request (deny on app level)""" + PolicyBinding.objects.create( + target=self.app, + policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), + order=0, + ) + License.objects.create(key=generate_id()) + self.client.force_login(self.user) + response = self.client.get( + reverse( + "authentik_providers_rac:start", + kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)}, + ) + ) + self.assertIsInstance(response, AccessDeniedResponse) + + @patch( + "authentik.enterprise.models.LicenseKey.validate", + MagicMock( + return_value=LicenseKey( + aud="", + exp=int(mktime((now() + timedelta(days=3000)).timetuple())), + name=generate_id(), + internal_users=100, + external_users=100, + ) + ), + ) + def test_endpoint_deny(self): + """Test request (deny on endpoint level)""" + PolicyBinding.objects.create( + target=self.endpoint, + policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), + order=0, + ) + License.objects.create(key=generate_id()) + self.client.force_login(self.user) + response = self.client.get( + reverse( + "authentik_providers_rac:start", + kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)}, + ) + ) + self.assertEqual(response.status_code, 302) + flow_response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + body = loads(flow_response.content) + self.assertEqual(body["component"], "ak-stage-access-denied") diff --git a/authentik/enterprise/providers/rac/urls.py b/authentik/enterprise/providers/rac/urls.py new file mode 100644 index 000000000..383619a3a --- /dev/null +++ b/authentik/enterprise/providers/rac/urls.py @@ -0,0 +1,47 @@ +"""rac urls""" +from channels.auth import AuthMiddleware +from channels.sessions import CookieMiddleware +from django.urls import path +from django.views.decorators.csrf import ensure_csrf_cookie + +from authentik.core.channels import TokenOutpostMiddleware +from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet +from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet +from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet +from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer +from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer +from authentik.enterprise.providers.rac.views import RACInterface, RACStartView +from authentik.root.asgi_middleware import SessionMiddleware +from authentik.root.middleware import ChannelsLoggingMiddleware + +urlpatterns = [ + path( + "application/rac///", + ensure_csrf_cookie(RACStartView.as_view()), + name="start", + ), + path( + "if/rac//", + ensure_csrf_cookie(RACInterface.as_view()), + name="if-rac", + ), +] + +websocket_urlpatterns = [ + path( + "ws/rac//", + ChannelsLoggingMiddleware( + CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi()))) + ), + ), + path( + "ws/outpost_rac//", + ChannelsLoggingMiddleware(TokenOutpostMiddleware(RACOutpostConsumer.as_asgi())), + ), +] + +api_urlpatterns = [ + ("providers/rac", RACProviderViewSet), + ("propertymappings/rac", RACPropertyMappingViewSet), + ("rac/endpoints", EndpointViewSet), +] diff --git a/authentik/enterprise/providers/rac/views.py b/authentik/enterprise/providers/rac/views.py new file mode 100644 index 000000000..31a25c721 --- /dev/null +++ b/authentik/enterprise/providers/rac/views.py @@ -0,0 +1,115 @@ +"""RAC Views""" +from typing import Any + +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.timezone import now + +from authentik.core.models import Application, AuthenticatedSession +from authentik.core.views.interface import InterfaceView +from authentik.enterprise.policy import EnterprisePolicyAccessView +from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider +from authentik.flows.challenge import RedirectChallenge +from authentik.flows.exceptions import FlowNonApplicableException +from authentik.flows.models import in_memory_stage +from authentik.flows.planner import FlowPlanner +from authentik.flows.stage import RedirectStage +from authentik.flows.views.executor import SESSION_KEY_PLAN +from authentik.lib.utils.time import timedelta_from_string +from authentik.lib.utils.urls import redirect_with_qs +from authentik.policies.engine import PolicyEngine + + +class RACStartView(EnterprisePolicyAccessView): + """Start a RAC connection by checking access and creating a connection token""" + + endpoint: Endpoint + + def resolve_provider_application(self): + self.application = get_object_or_404(Application, slug=self.kwargs["app"]) + # Endpoint permissions are validated in the RACFinalStage below + self.endpoint = get_object_or_404(Endpoint, pk=self.kwargs["endpoint"]) + self.provider = RACProvider.objects.get(application=self.application) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Start flow planner for RAC provider""" + planner = FlowPlanner(self.provider.authorization_flow) + planner.allow_empty_flows = True + try: + plan = planner.plan(self.request) + except FlowNonApplicableException: + raise Http404 + plan.insert_stage( + in_memory_stage( + RACFinalStage, + endpoint=self.endpoint, + provider=self.provider, + ) + ) + request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_core:if-flow", + request.GET, + flow_slug=self.provider.authorization_flow.slug, + ) + + +class RACInterface(InterfaceView): + """Start RAC connection""" + + template_name = "if/rac.html" + token: ConnectionToken + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + # Early sanity check to ensure token still exists + token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first() + if not token: + return redirect("authentik_core:if-user") + self.token = token + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + kwargs["token"] = self.token + return super().get_context_data(**kwargs) + + +class RACFinalStage(RedirectStage): + """RAC Connection final stage, set the connection token in the stage""" + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + endpoint: Endpoint = self.executor.current_stage.endpoint + engine = PolicyEngine(endpoint, self.request.user, self.request) + engine.use_cache = False + engine.build() + passing = engine.result + if not passing.passing: + return self.executor.stage_invalid(", ".join(passing.messages)) + return super().dispatch(request, *args, **kwargs) + + def get_challenge(self, *args, **kwargs) -> RedirectChallenge: + endpoint: Endpoint = self.executor.current_stage.endpoint + provider: RACProvider = self.executor.current_stage.provider + token = ConnectionToken.objects.create( + provider=provider, + endpoint=endpoint, + settings=self.executor.plan.context.get("connection_settings", {}), + session=AuthenticatedSession.objects.filter( + session_key=self.request.session.session_key + ).first(), + expires=now() + timedelta_from_string(provider.connection_expiry), + expiring=True, + ) + setattr( + self.executor.current_stage, + "destination", + self.request.build_absolute_uri( + reverse( + "authentik_providers_rac:if-rac", + kwargs={ + "token": str(token.token), + }, + ) + ), + ) + return super().get_challenge(*args, **kwargs) diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 87aaea71b..f83a327dc 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -10,3 +10,7 @@ CELERY_BEAT_SCHEDULE = { "options": {"queue": "authentik_scheduled"}, } } + +INSTALLED_APPS = [ + "authentik.enterprise.providers.rac", +] diff --git a/authentik/events/api/events.py b/authentik/events/api/events.py index d2e89ae5b..18b60be2c 100644 --- a/authentik/events/api/events.py +++ b/authentik/events/api/events.py @@ -6,6 +6,7 @@ import django_filters from django.db.models.aggregates import Count from django.db.models.fields.json import KeyTextTransform, KeyTransform from django.db.models.functions import ExtractDay, ExtractHour +from django.db.models.query_utils import Q from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from guardian.shortcuts import get_objects_for_user @@ -87,7 +88,12 @@ class EventsFilter(django_filters.FilterSet): we need to remove the dashes that a client may send. We can't use a UUIDField for this, as some models might not have a UUID PK""" value = str(value).replace("-", "") - return queryset.filter(context__model__pk=value) + query = Q(context__model__pk=value) + try: + query |= Q(context__model__pk=int(value)) + except ValueError: + pass + return queryset.filter(query) class Meta: model = Event diff --git a/authentik/events/tests/test_api.py b/authentik/events/tests/test_api.py index 1225d0665..98df7bc69 100644 --- a/authentik/events/tests/test_api.py +++ b/authentik/events/tests/test_api.py @@ -1,4 +1,5 @@ """Event API tests""" +from json import loads from django.urls import reverse from rest_framework.test import APITestCase @@ -11,6 +12,9 @@ from authentik.events.models import ( NotificationSeverity, TransportMode, ) +from authentik.events.utils import model_to_dict +from authentik.lib.generators import generate_id +from authentik.providers.oauth2.models import OAuth2Provider class TestEventsAPI(APITestCase): @@ -20,6 +24,25 @@ class TestEventsAPI(APITestCase): self.user = create_test_admin_user() self.client.force_login(self.user) + def test_filter_model_pk_int(self): + """Test event list with context_model_pk and integer PKs""" + provider = OAuth2Provider.objects.create( + name=generate_id(), + ) + event = Event.new(EventAction.MODEL_CREATED, model=model_to_dict(provider)) + event.save() + response = self.client.get( + reverse("authentik_api:event-list"), + data={ + "context_model_pk": provider.pk, + "context_model_app": "authentik_providers_oauth2", + "context_model_name": "oauth2provider", + }, + ) + self.assertEqual(response.status_code, 200) + body = loads(response.content) + self.assertEqual(body["pagination"]["count"], 1) + def test_top_n(self): """Test top_per_user""" event = Event.new(EventAction.AUTHORIZE_APPLICATION) diff --git a/authentik/outposts/api/outposts.py b/authentik/outposts/api/outposts.py index 6219d68ea..182ec4dbf 100644 --- a/authentik/outposts/api/outposts.py +++ b/authentik/outposts/api/outposts.py @@ -17,6 +17,7 @@ from authentik.core.api.providers import ProviderSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.models import Provider +from authentik.enterprise.providers.rac.models import RACProvider from authentik.outposts.api.service_connections import ServiceConnectionSerializer from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME from authentik.outposts.models import ( @@ -63,6 +64,7 @@ class OutpostSerializer(ModelSerializer): OutpostType.LDAP: LDAPProvider, OutpostType.PROXY: ProxyProvider, OutpostType.RADIUS: RadiusProvider, + OutpostType.RAC: RACProvider, None: Provider, } for provider in providers: diff --git a/authentik/outposts/consumer.py b/authentik/outposts/consumer.py index dda3feed0..6ed2926b8 100644 --- a/authentik/outposts/consumer.py +++ b/authentik/outposts/consumer.py @@ -6,16 +6,18 @@ from typing import Any, Optional from asgiref.sync import async_to_sync from channels.exceptions import DenyConnection +from channels.generic.websocket import JsonWebsocketConsumer from dacite.core import from_dict from dacite.data import Data +from django.http.request import QueryDict from guardian.shortcuts import get_objects_for_user from structlog.stdlib import BoundLogger, get_logger -from authentik.core.channels import AuthJsonConsumer from authentik.outposts.apps import GAUGE_OUTPOSTS_CONNECTED, GAUGE_OUTPOSTS_LAST_UPDATE from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState OUTPOST_GROUP = "group_outpost_%(outpost_pk)s" +OUTPOST_GROUP_INSTANCE = "group_outpost_%(outpost_pk)s_%(instance)s" class WebsocketMessageInstruction(IntEnum): @@ -42,25 +44,23 @@ class WebsocketMessage: args: dict[str, Any] = field(default_factory=dict) -class OutpostConsumer(AuthJsonConsumer): +class OutpostConsumer(JsonWebsocketConsumer): """Handler for Outposts that connect over websockets for health checks and live updates""" outpost: Optional[Outpost] = None logger: BoundLogger - last_uid: Optional[str] = None + instance_uid: Optional[str] = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.logger = get_logger() def connect(self): - super().connect() uuid = self.scope["url_route"]["kwargs"]["pk"] + user = self.scope["user"] outpost = ( - get_objects_for_user(self.user, "authentik_outposts.view_outpost") - .filter(pk=uuid) - .first() + get_objects_for_user(user, "authentik_outposts.view_outpost").filter(pk=uuid).first() ) if not outpost: raise DenyConnection() @@ -71,13 +71,19 @@ class OutpostConsumer(AuthJsonConsumer): self.logger.warning("runtime error during accept", exc=exc) raise DenyConnection() self.outpost = outpost - self.last_uid = self.channel_name + query = QueryDict(self.scope["query_string"].decode()) + self.instance_uid = query.get("instance_uuid", self.channel_name) async_to_sync(self.channel_layer.group_add)( OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name ) + async_to_sync(self.channel_layer.group_add)( + OUTPOST_GROUP_INSTANCE + % {"outpost_pk": str(self.outpost.pk), "instance": self.instance_uid}, + self.channel_name, + ) GAUGE_OUTPOSTS_CONNECTED.labels( outpost=self.outpost.name, - uid=self.last_uid, + uid=self.instance_uid, expected=self.outpost.config.kubernetes_replicas, ).inc() @@ -86,34 +92,37 @@ class OutpostConsumer(AuthJsonConsumer): async_to_sync(self.channel_layer.group_discard)( OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name ) - if self.outpost and self.last_uid: + if self.instance_uid: + async_to_sync(self.channel_layer.group_discard)( + OUTPOST_GROUP_INSTANCE + % {"outpost_pk": str(self.outpost.pk), "instance": self.instance_uid}, + self.channel_name, + ) + if self.outpost and self.instance_uid: GAUGE_OUTPOSTS_CONNECTED.labels( outpost=self.outpost.name, - uid=self.last_uid, + uid=self.instance_uid, expected=self.outpost.config.kubernetes_replicas, ).dec() def receive_json(self, content: Data, **kwargs): msg = from_dict(WebsocketMessage, content) - uid = msg.args.get("uuid", self.channel_name) - self.last_uid = uid - if not self.outpost: raise DenyConnection() - state = OutpostState.for_instance_uid(self.outpost, uid) + state = OutpostState.for_instance_uid(self.outpost, self.instance_uid) state.last_seen = datetime.now() state.hostname = msg.args.pop("hostname", "") if msg.instruction == WebsocketMessageInstruction.HELLO: state.version = msg.args.pop("version", None) state.build_hash = msg.args.pop("buildHash", "") - state.args = msg.args + state.args.update(msg.args) elif msg.instruction == WebsocketMessageInstruction.ACK: return GAUGE_OUTPOSTS_LAST_UPDATE.labels( outpost=self.outpost.name, - uid=self.last_uid or "", + uid=self.instance_uid or "", version=state.version or "", ).set_to_current_time() state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) diff --git a/authentik/outposts/controllers/k8s/utils.py b/authentik/outposts/controllers/k8s/utils.py index d1f01811f..e9c83f975 100644 --- a/authentik/outposts/controllers/k8s/utils.py +++ b/authentik/outposts/controllers/k8s/utils.py @@ -1,5 +1,6 @@ """k8s utils""" from pathlib import Path +from typing import Optional from kubernetes.client.models.v1_container_port import V1ContainerPort from kubernetes.client.models.v1_service_port import V1ServicePort @@ -37,9 +38,12 @@ def compare_port( def compare_ports( - current: list[V1ServicePort | V1ContainerPort], reference: list[V1ServicePort | V1ContainerPort] + current: Optional[list[V1ServicePort | V1ContainerPort]], + reference: Optional[list[V1ServicePort | V1ContainerPort]], ): """Compare ports of a list""" + if not current or not reference: + raise NeedsRecreate() if len(current) != len(reference): raise NeedsRecreate() for port in reference: diff --git a/authentik/outposts/controllers/kubernetes.py b/authentik/outposts/controllers/kubernetes.py index e3a943e2c..e3b358078 100644 --- a/authentik/outposts/controllers/kubernetes.py +++ b/authentik/outposts/controllers/kubernetes.py @@ -81,7 +81,10 @@ class KubernetesController(BaseController): def up(self): try: for reconcile_key in self.reconcile_order: - reconciler = self.reconcilers[reconcile_key](self) + reconciler_cls = self.reconcilers.get(reconcile_key) + if not reconciler_cls: + continue + reconciler = reconciler_cls(self) reconciler.up() except (OpenApiException, HTTPError, ServiceConnectionInvalid) as exc: @@ -95,7 +98,10 @@ class KubernetesController(BaseController): all_logs += [f"{reconcile_key.title()}: Disabled"] continue with capture_logs() as logs: - reconciler = self.reconcilers[reconcile_key](self) + reconciler_cls = self.reconcilers.get(reconcile_key) + if not reconciler_cls: + continue + reconciler = reconciler_cls(self) reconciler.up() all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs] return all_logs @@ -105,7 +111,10 @@ class KubernetesController(BaseController): def down(self): try: for reconcile_key in self.reconcile_order: - reconciler = self.reconcilers[reconcile_key](self) + reconciler_cls = self.reconcilers.get(reconcile_key) + if not reconciler_cls: + continue + reconciler = reconciler_cls(self) self.logger.debug("Tearing down object", name=reconcile_key) reconciler.down() @@ -120,7 +129,10 @@ class KubernetesController(BaseController): all_logs += [f"{reconcile_key.title()}: Disabled"] continue with capture_logs() as logs: - reconciler = self.reconcilers[reconcile_key](self) + reconciler_cls = self.reconcilers.get(reconcile_key) + if not reconciler_cls: + continue + reconciler = reconciler_cls(self) reconciler.down() all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs] return all_logs @@ -130,7 +142,10 @@ class KubernetesController(BaseController): def get_static_deployment(self) -> str: documents = [] for reconcile_key in self.reconcile_order: - reconciler = self.reconcilers[reconcile_key](self) + reconciler_cls = self.reconcilers.get(reconcile_key) + if not reconciler_cls: + continue + reconciler = reconciler_cls(self) if reconciler.noop: continue documents.append(reconciler.get_reference_object().to_dict()) diff --git a/authentik/outposts/migrations/0021_alter_outpost_type.py b/authentik/outposts/migrations/0021_alter_outpost_type.py new file mode 100644 index 000000000..52fcf1fd5 --- /dev/null +++ b/authentik/outposts/migrations/0021_alter_outpost_type.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-10-14 19:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_outposts", "0020_alter_outpost_type"), + ] + + operations = [ + migrations.AlterField( + model_name="outpost", + name="type", + field=models.TextField( + choices=[ + ("proxy", "Proxy"), + ("ldap", "Ldap"), + ("radius", "Radius"), + ("rac", "Rac"), + ], + default="proxy", + ), + ), + ] diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 14f896c35..bcaeda8b8 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -90,11 +90,12 @@ class OutpostModel(Model): class OutpostType(models.TextChoices): - """Outpost types, currently only the reverse proxy is available""" + """Outpost types""" PROXY = "proxy" LDAP = "ldap" RADIUS = "radius" + RAC = "rac" def default_outpost_config(host: Optional[str] = None): @@ -459,7 +460,7 @@ class OutpostState: def for_instance_uid(outpost: Outpost, uid: str) -> "OutpostState": """Get state for a single instance""" key = f"{outpost.state_cache_prefix}/{uid}" - default_data = {"uid": uid, "channel_ids": []} + default_data = {"uid": uid} data = cache.get(key, default_data) if isinstance(data, str): cache.delete(key) diff --git a/authentik/outposts/tasks.py b/authentik/outposts/tasks.py index b6b3a9bab..0d4e54a3a 100644 --- a/authentik/outposts/tasks.py +++ b/authentik/outposts/tasks.py @@ -17,6 +17,8 @@ from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION from structlog.stdlib import get_logger from yaml import safe_load +from authentik.enterprise.providers.rac.controllers.docker import RACDockerController +from authentik.enterprise.providers.rac.controllers.kubernetes import RACKubernetesController from authentik.events.monitored_tasks import ( MonitoredTask, TaskResult, @@ -71,6 +73,11 @@ def controller_for_outpost(outpost: Outpost) -> Optional[type[BaseController]]: return RadiusDockerController if isinstance(service_connection, KubernetesServiceConnection): return RadiusKubernetesController + if outpost.type == OutpostType.RAC: + if isinstance(service_connection, DockerServiceConnection): + return RACDockerController + if isinstance(service_connection, KubernetesServiceConnection): + return RACKubernetesController return None diff --git a/authentik/outposts/tests/test_ws.py b/authentik/outposts/tests/test_ws.py index b8fcba925..ec3d543a3 100644 --- a/authentik/outposts/tests/test_ws.py +++ b/authentik/outposts/tests/test_ws.py @@ -1,6 +1,7 @@ """Websocket tests""" from dataclasses import asdict +from channels.exceptions import DenyConnection from channels.routing import URLRouter from channels.testing import WebsocketCommunicator from django.test import TransactionTestCase @@ -35,8 +36,9 @@ class TestOutpostWS(TransactionTestCase): communicator = WebsocketCommunicator( URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/" ) - connected, _ = await communicator.connect() - self.assertFalse(connected) + with self.assertRaises(DenyConnection): + connected, _ = await communicator.connect() + self.assertFalse(connected) async def test_auth_valid(self): """Test auth with token""" diff --git a/authentik/outposts/urls.py b/authentik/outposts/urls.py index cd7ba3bf8..9d28a01eb 100644 --- a/authentik/outposts/urls.py +++ b/authentik/outposts/urls.py @@ -1,6 +1,7 @@ """Outpost Websocket URLS""" from django.urls import path +from authentik.core.channels import TokenOutpostMiddleware from authentik.outposts.api.outposts import OutpostViewSet from authentik.outposts.api.service_connections import ( DockerServiceConnectionViewSet, @@ -11,7 +12,10 @@ from authentik.outposts.consumer import OutpostConsumer from authentik.root.middleware import ChannelsLoggingMiddleware websocket_urlpatterns = [ - path("ws/outpost//", ChannelsLoggingMiddleware(OutpostConsumer.as_asgi())), + path( + "ws/outpost//", + ChannelsLoggingMiddleware(TokenOutpostMiddleware(OutpostConsumer.as_asgi())), + ), ] api_urlpatterns = [ diff --git a/blueprints/schema.json b/blueprints/schema.json index 523f6e10d..07d9cd227 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -2779,6 +2779,117 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_providers_rac.racprovider" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_providers_rac.racprovider" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_providers_rac.racprovider" + } + } + }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_providers_rac.endpoint" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_providers_rac.endpoint" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_providers_rac.endpoint" + } + } + }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_providers_rac.racpropertymapping" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_providers_rac.racpropertymapping" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_providers_rac.racpropertymapping" + } + } + }, { "type": "object", "required": [ @@ -3296,7 +3407,8 @@ "enum": [ "proxy", "ldap", - "radius" + "radius", + "rac" ], "title": "Type" }, @@ -3476,7 +3588,8 @@ "authentik.tenants", "authentik.blueprints", "authentik.core", - "authentik.enterprise" + "authentik.enterprise", + "authentik.enterprise.providers.rac" ], "title": "App", "description": "Match events created by selected application. When left empty, all applications are matched." @@ -3561,7 +3674,10 @@ "authentik_core.user", "authentik_core.application", "authentik_core.token", - "authentik_enterprise.license" + "authentik_enterprise.license", + "authentik_providers_rac.racprovider", + "authentik_providers_rac.endpoint", + "authentik_providers_rac.racpropertymapping" ], "title": "Model", "description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched." @@ -8758,6 +8874,123 @@ }, "required": [] }, + "model_authentik_providers_rac.racprovider": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "authentication_flow": { + "type": "integer", + "title": "Authentication flow", + "description": "Flow used for authentication when the associated application is accessed by an un-authenticated user." + }, + "authorization_flow": { + "type": "integer", + "title": "Authorization flow", + "description": "Flow used when authorizing this provider." + }, + "property_mappings": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "Property mappings" + }, + "settings": { + "type": "object", + "additionalProperties": true, + "title": "Settings" + }, + "connection_expiry": { + "type": "string", + "minLength": 1, + "title": "Connection expiry", + "description": "Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)" + } + }, + "required": [] + }, + "model_authentik_providers_rac.endpoint": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "provider": { + "type": "integer", + "title": "Provider" + }, + "protocol": { + "type": "string", + "enum": [ + "rdp", + "vnc", + "ssh" + ], + "title": "Protocol" + }, + "host": { + "type": "string", + "minLength": 1, + "title": "Host" + }, + "settings": { + "type": "object", + "additionalProperties": true, + "title": "Settings" + }, + "property_mappings": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "Property mappings" + }, + "auth_mode": { + "type": "string", + "enum": [ + "static", + "prompt" + ], + "title": "Auth mode" + } + }, + "required": [] + }, + "model_authentik_providers_rac.racpropertymapping": { + "type": "object", + "properties": { + "managed": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "title": "Managed by authentik", + "description": "Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update." + }, + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "expression": { + "type": "string", + "title": "Expression" + }, + "static_settings": { + "type": "object", + "additionalProperties": true, + "title": "Static settings" + } + }, + "required": [] + }, "model_authentik_blueprints.metaapplyblueprint": { "type": "object", "properties": { diff --git a/blueprints/system/providers-rac.yaml b/blueprints/system/providers-rac.yaml new file mode 100644 index 000000000..63a568673 --- /dev/null +++ b/blueprints/system/providers-rac.yaml @@ -0,0 +1,32 @@ +version: 1 +metadata: + labels: + blueprints.goauthentik.io/system: "true" + name: System - RAC Provider - Mappings +entries: + - identifiers: + managed: goauthentik.io/providers/rac/rdp-default + model: authentik_providers_rac.racpropertymapping + attrs: + name: "authentik default RAC Mapping: RDP Default settings" + static_settings: + resize-method: "display-update" + enable-wallpaper: "true" + enable-font-smoothing: "true" + - identifiers: + managed: goauthentik.io/providers/rac/rdp-high-fidelity + model: authentik_providers_rac.racpropertymapping + attrs: + name: "authentik default RAC Mapping: RDP High Fidelity" + static_settings: + enable-theming: "true" + enable-full-window-drag: "true" + enable-desktop-composition: "true" + enable-menu-animations: "true" + - identifiers: + managed: goauthentik.io/providers/rac/ssh-default + model: authentik_providers_rac.racpropertymapping + attrs: + name: "authentik default RAC Mapping: SSH Default settings" + static_settings: + terminal-type: "xterm-256color" diff --git a/cmd/rac/main.go b/cmd/rac/main.go new file mode 100644 index 000000000..947ad14dd --- /dev/null +++ b/cmd/rac/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "net/url" + "os" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "goauthentik.io/internal/common" + "goauthentik.io/internal/debug" + "goauthentik.io/internal/outpost/ak" + "goauthentik.io/internal/outpost/ak/healthcheck" + "goauthentik.io/internal/outpost/rac" +) + +const helpMessage = `authentik RAC + +Required environment variables: +- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company") +- AUTHENTIK_TOKEN: Token to authenticate with +- AUTHENTIK_INSECURE: Skip SSL Certificate verification` + +var rootCmd = &cobra.Command{ + Long: helpMessage, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + log.SetLevel(log.DebugLevel) + log.SetFormatter(&log.JSONFormatter{ + FieldMap: log.FieldMap{ + log.FieldKeyMsg: "event", + log.FieldKeyTime: "timestamp", + }, + DisableHTMLEscape: true, + }) + }, + Run: func(cmd *cobra.Command, args []string) { + debug.EnableDebugServer() + akURL, found := os.LookupEnv("AUTHENTIK_HOST") + if !found { + fmt.Println("env AUTHENTIK_HOST not set!") + fmt.Println(helpMessage) + os.Exit(1) + } + akToken, found := os.LookupEnv("AUTHENTIK_TOKEN") + if !found { + fmt.Println("env AUTHENTIK_TOKEN not set!") + fmt.Println(helpMessage) + os.Exit(1) + } + + akURLActual, err := url.Parse(akURL) + if err != nil { + fmt.Println(err) + fmt.Println(helpMessage) + os.Exit(1) + } + + ex := common.Init() + defer common.Defer() + go func() { + for { + <-ex + os.Exit(0) + } + }() + + ac := ak.NewAPIController(*akURLActual, akToken) + if ac == nil { + os.Exit(1) + } + defer ac.Shutdown() + + ac.Server = rac.NewServer(ac) + + err = ac.Start() + if err != nil { + log.WithError(err).Panic("Failed to run server") + } + + for { + <-ex + } + }, +} + +func main() { + rootCmd.AddCommand(healthcheck.Command) + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 52f4ac7c5..269d4c295 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 + github.com/wwt/guac v1.3.2 goauthentik.io/api/v3 v3.2023105.2 golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/oauth2 v0.15.0 diff --git a/go.sum b/go.sum index e181e30db..d9acde526 100644 --- a/go.sum +++ b/go.sum @@ -195,6 +195,7 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -210,6 +211,7 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -262,6 +264,7 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= @@ -269,8 +272,10 @@ github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyh github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -281,6 +286,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4= +github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= @@ -414,6 +421,7 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/outpost/ak/api.go b/internal/outpost/ak/api.go index 34a30cbea..1f744010a 100644 --- a/internal/outpost/ak/api.go +++ b/internal/outpost/ak/api.go @@ -159,8 +159,8 @@ func (a *APIController) AddRefreshHandler(handler func()) { a.refreshHandlers = append(a.refreshHandlers, handler) } -func (a *APIController) AddWSHandler(handler WSHandler) { - a.wsHandlers = append(a.wsHandlers, handler) +func (a *APIController) Token() string { + return a.token } func (a *APIController) OnRefresh() error { @@ -182,7 +182,7 @@ func (a *APIController) OnRefresh() error { return err } -func (a *APIController) getWebsocketArgs() map[string]interface{} { +func (a *APIController) getWebsocketPingArgs() map[string]interface{} { args := map[string]interface{}{ "version": constants.VERSION, "buildHash": constants.BUILD("tagged"), diff --git a/internal/outpost/ak/api_ws.go b/internal/outpost/ak/api_ws.go index 24c5099f4..c48cebba3 100644 --- a/internal/outpost/ak/api_ws.go +++ b/internal/outpost/ak/api_ws.go @@ -18,6 +18,8 @@ import ( func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error { pathTemplate := "%s://%s/ws/outpost/%s/?%s" + query := akURL.Query() + query.Set("instance_uuid", ac.instanceUUID.String()) scheme := strings.ReplaceAll(akURL.Scheme, "http", "ws") authHeader := fmt.Sprintf("Bearer %s", ac.token) @@ -45,7 +47,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error { // Send hello message with our version msg := websocketMessage{ Instruction: WebsocketInstructionHello, - Args: ac.getWebsocketArgs(), + Args: ac.getWebsocketPingArgs(), } err = ws.WriteJSON(msg) if err != nil { @@ -53,7 +55,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error { return err } ac.lastWsReconnect = time.Now() - ac.logger.WithField("logger", "authentik.outpost.ak-ws").WithField("outpost", outpostUUID).Debug("Successfully connected websocket") + ac.logger.WithField("logger", "authentik.outpost.ak-ws").WithField("outpost", outpostUUID).Info("Successfully connected websocket") return nil } @@ -157,23 +159,19 @@ func (ac *APIController) startWSHandler() { func (ac *APIController) startWSHealth() { ticker := time.NewTicker(time.Second * 10) for ; true; <-ticker.C { - aliveMsg := websocketMessage{ - Instruction: WebsocketInstructionHello, - Args: ac.getWebsocketArgs(), - } if ac.wsConn == nil { go ac.reconnectWS() time.Sleep(time.Second * 5) continue } - err := ac.wsConn.WriteJSON(aliveMsg) - ac.logger.WithField("loop", "ws-health").Trace("hello'd") + err := ac.SendWSHello(map[string]interface{}{}) if err != nil { ac.logger.WithField("loop", "ws-health").WithError(err).Warning("ws write error") go ac.reconnectWS() time.Sleep(time.Second * 5) continue } else { + ac.logger.WithField("loop", "ws-health").Trace("hello'd") ConnectionStatus.With(prometheus.Labels{ "outpost_name": ac.Outpost.Name, "outpost_type": ac.Server.Type(), @@ -202,3 +200,20 @@ func (ac *APIController) startIntervalUpdater() { } } } + +func (a *APIController) AddWSHandler(handler WSHandler) { + a.wsHandlers = append(a.wsHandlers, handler) +} + +func (a *APIController) SendWSHello(args map[string]interface{}) error { + allArgs := a.getWebsocketPingArgs() + for key, value := range args { + allArgs[key] = value + } + aliveMsg := websocketMessage{ + Instruction: WebsocketInstructionHello, + Args: allArgs, + } + err := a.wsConn.WriteJSON(aliveMsg) + return err +} diff --git a/internal/outpost/rac/connection/connection.go b/internal/outpost/rac/connection/connection.go new file mode 100644 index 000000000..53ca9ecb0 --- /dev/null +++ b/internal/outpost/rac/connection/connection.go @@ -0,0 +1,124 @@ +package connection + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" + "github.com/wwt/guac" + "goauthentik.io/internal/config" + "goauthentik.io/internal/constants" + "goauthentik.io/internal/outpost/ak" +) + +const guacAddr = "0.0.0.0:4822" + +type Connection struct { + log *log.Entry + st *guac.SimpleTunnel + ac *ak.APIController + ws *websocket.Conn + ctx context.Context + ctxCancel context.CancelFunc + OnError func(error) + closing bool +} + +func NewConnection(ac *ak.APIController, forChannel string, cfg *guac.Config) (*Connection, error) { + ctx, canc := context.WithCancel(context.Background()) + c := &Connection{ + ac: ac, + log: log.WithField("connection", forChannel), + ctx: ctx, + ctxCancel: canc, + OnError: func(err error) {}, + closing: false, + } + err := c.initGuac(cfg) + if err != nil { + return nil, err + } + err = c.initSocket(forChannel) + if err != nil { + _ = c.st.Close() + return nil, err + } + c.initMirror() + return c, nil +} + +func (c *Connection) initSocket(forChannel string) error { + pathTemplate := "%s://%s/ws/outpost_rac/%s/" + scheme := strings.ReplaceAll(c.ac.Client.GetConfig().Scheme, "http", "ws") + + authHeader := fmt.Sprintf("Bearer %s", c.ac.Token()) + + header := http.Header{ + "Authorization": []string{authHeader}, + "User-Agent": []string{constants.OutpostUserAgent()}, + } + + dialer := websocket.Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: config.Get().AuthentikInsecure, + }, + } + + url := fmt.Sprintf(pathTemplate, scheme, c.ac.Client.GetConfig().Host, forChannel) + ws, _, err := dialer.Dial(url, header) + if err != nil { + c.log.WithError(err).Warning("failed to connect websocket") + return err + } + c.ws = ws + return nil +} + +func (c *Connection) initGuac(cfg *guac.Config) error { + addr, err := net.ResolveTCPAddr("tcp", guacAddr) + if err != nil { + return err + } + + conn, err := net.DialTCP("tcp", nil, addr) + if err != nil { + return err + } + + stream := guac.NewStream(conn, guac.SocketTimeout) + + err = stream.Handshake(cfg) + if err != nil { + return err + } + st := guac.NewSimpleTunnel(stream) + c.st = st + return nil +} + +func (c *Connection) initMirror() { + go c.wsToGuacd() + go c.guacdToWs() +} + +func (c *Connection) onError(err error) { + if c.closing { + return + } + c.closing = true + e := c.st.Close() + if e != nil { + c.log.WithError(e).Warning("failed to close guacd connection") + } + c.log.WithError(err).Info("removing connection") + c.ctxCancel() + c.OnError(err) +} diff --git a/internal/outpost/rac/connection/mirror.go b/internal/outpost/rac/connection/mirror.go new file mode 100644 index 000000000..7475c6efc --- /dev/null +++ b/internal/outpost/rac/connection/mirror.go @@ -0,0 +1,103 @@ +package connection + +import ( + "bytes" + "fmt" + + "github.com/gorilla/websocket" + "github.com/wwt/guac" +) + +var ( + internalOpcodeIns = []byte(fmt.Sprint(len(guac.InternalDataOpcode), ".", guac.InternalDataOpcode)) + authentikOpcode = []byte("0.authentik.") +) + +// MessageReader wraps a websocket connection and only permits Reading +type MessageReader interface { + // ReadMessage should return a single complete message to send to guac + ReadMessage() (int, []byte, error) +} + +func (c *Connection) wsToGuacd() { + w := c.st.AcquireWriter() + for { + select { + default: + _, data, e := c.ws.ReadMessage() + if e != nil { + c.log.WithError(e).Trace("Error reading message from ws") + c.onError(e) + return + } + if bytes.HasPrefix(data, internalOpcodeIns) { + if bytes.HasPrefix(data, authentikOpcode) { + switch string(bytes.Replace(data, authentikOpcode, []byte{}, 1)) { + case "disconnect": + _, e := w.Write([]byte(guac.NewInstruction("disconnect").String())) + c.onError(e) + return + } + } + // messages starting with the InternalDataOpcode are never sent to guacd + continue + } + + if _, e = w.Write(data); e != nil { + c.log.WithError(e).Trace("Failed writing to guacd") + c.onError(e) + return + } + case <-c.ctx.Done(): + return + } + } +} + +// MessageWriter wraps a websocket connection and only permits Writing +type MessageWriter interface { + // WriteMessage writes one or more complete guac commands to the websocket + WriteMessage(int, []byte) error +} + +func (c *Connection) guacdToWs() { + r := c.st.AcquireReader() + buf := bytes.NewBuffer(make([]byte, 0, guac.MaxGuacMessage*2)) + for { + select { + default: + ins, e := r.ReadSome() + if e != nil { + c.log.WithError(e).Trace("Error reading from guacd") + c.onError(e) + return + } + + if bytes.HasPrefix(ins, internalOpcodeIns) { + // messages starting with the InternalDataOpcode are never sent to the websocket + continue + } + + if _, e = buf.Write(ins); e != nil { + c.log.WithError(e).Trace("Failed to buffer guacd to ws") + c.onError(e) + return + } + + // if the buffer has more data in it or we've reached the max buffer size, send the data and reset + if !r.Available() || buf.Len() >= guac.MaxGuacMessage { + if e = c.ws.WriteMessage(1, buf.Bytes()); e != nil { + if e == websocket.ErrCloseSent { + return + } + c.log.WithError(e).Trace("Failed sending message to ws") + c.onError(e) + return + } + buf.Reset() + } + case <-c.ctx.Done(): + return + } + } +} diff --git a/internal/outpost/rac/guacd.go b/internal/outpost/rac/guacd.go new file mode 100644 index 000000000..3ae0c4f3f --- /dev/null +++ b/internal/outpost/rac/guacd.go @@ -0,0 +1,26 @@ +package rac + +import ( + "os" + "os/exec" + "strings" + + log "github.com/sirupsen/logrus" + "goauthentik.io/internal/outpost/ak" +) + +const ( + guacdPath = "/opt/guacamole/sbin/guacd" + guacdDefaultArgs = " -b 0.0.0.0 -f" +) + +func (rs *RACServer) startGuac() error { + guacdArgs := strings.Split(guacdDefaultArgs, " ") + guacdArgs = append(guacdArgs, "-L", rs.ac.Outpost.Config[ak.ConfigLogLevel].(string)) + rs.guacd = exec.Command(guacdPath, guacdArgs...) + rs.guacd.Env = os.Environ() + rs.guacd.Stdout = rs.log.WithField("logger", "authentik.outpost.rac.guacd").WriterLevel(log.InfoLevel) + rs.guacd.Stderr = rs.log.WithField("logger", "authentik.outpost.rac.guacd").WriterLevel(log.InfoLevel) + rs.log.Info("starting guacd") + return rs.guacd.Start() +} diff --git a/internal/outpost/rac/metrics/metrics.go b/internal/outpost/rac/metrics/metrics.go new file mode 100644 index 000000000..0a3e6b45d --- /dev/null +++ b/internal/outpost/rac/metrics/metrics.go @@ -0,0 +1,28 @@ +package metrics + +import ( + "net/http" + + log "github.com/sirupsen/logrus" + "goauthentik.io/internal/config" + "goauthentik.io/internal/utils/sentry" + + "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func RunServer() { + m := mux.NewRouter() + l := log.WithField("logger", "authentik.outpost.metrics") + m.Use(sentry.SentryNoSampleMiddleware) + m.HandleFunc("/outpost.goauthentik.io/ping", func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(204) + }) + m.Path("/metrics").Handler(promhttp.Handler()) + listen := config.Get().Listen.Metrics + l.WithField("listen", listen).Info("Starting Metrics server") + err := http.ListenAndServe(listen, m) + if err != nil { + l.WithError(err).Warning("Failed to start metrics listener") + } +} diff --git a/internal/outpost/rac/rac.go b/internal/outpost/rac/rac.go new file mode 100644 index 000000000..1e9920305 --- /dev/null +++ b/internal/outpost/rac/rac.go @@ -0,0 +1,126 @@ +package rac + +import ( + "context" + "os/exec" + "strconv" + "sync" + + "github.com/mitchellh/mapstructure" + log "github.com/sirupsen/logrus" + "github.com/wwt/guac" + + "goauthentik.io/internal/outpost/ak" + "goauthentik.io/internal/outpost/rac/connection" + "goauthentik.io/internal/outpost/rac/metrics" +) + +type RACServer struct { + log *log.Entry + ac *ak.APIController + guacd *exec.Cmd + connm sync.RWMutex + conns map[string]connection.Connection +} + +func NewServer(ac *ak.APIController) *RACServer { + rs := &RACServer{ + log: log.WithField("logger", "authentik.outpost.rac"), + ac: ac, + connm: sync.RWMutex{}, + conns: map[string]connection.Connection{}, + } + ac.AddWSHandler(rs.wsHandler) + return rs +} + +type WSMessage struct { + ConnID string `mapstructure:"conn_id"` + DestChannelID string `mapstructure:"dest_channel_id"` + Params map[string]string `mapstructure:"params"` + Protocol string `mapstructure:"protocol"` + OptimalScreenWidth string `mapstructure:"screen_width"` + OptimalScreenHeight string `mapstructure:"screen_height"` + OptimalScreenDPI string `mapstructure:"screen_dpi"` +} + +func parseIntOrZero(input string) int { + x, err := strconv.Atoi(input) + if err != nil { + return 0 + } + return x +} + +func (rs *RACServer) wsHandler(ctx context.Context, args map[string]interface{}) { + wsm := WSMessage{} + err := mapstructure.Decode(args, &wsm) + if err != nil { + rs.log.WithError(err).Warning("invalid ws message") + return + } + config := guac.NewGuacamoleConfiguration() + config.Protocol = wsm.Protocol + config.Parameters = wsm.Params + config.OptimalScreenWidth = parseIntOrZero(wsm.OptimalScreenWidth) + config.OptimalScreenHeight = parseIntOrZero(wsm.OptimalScreenHeight) + config.OptimalResolution = parseIntOrZero(wsm.OptimalScreenDPI) + config.AudioMimetypes = []string{ + "audio/L8", + "audio/L16", + } + cc, err := connection.NewConnection(rs.ac, wsm.DestChannelID, config) + if err != nil { + rs.log.WithError(err).Warning("failed to setup connection") + return + } + cc.OnError = func(err error) { + rs.connm.Lock() + delete(rs.conns, wsm.ConnID) + _ = rs.ac.SendWSHello(map[string]interface{}{ + "active_connections": len(rs.conns), + }) + rs.connm.Unlock() + } + rs.connm.Lock() + rs.conns[wsm.ConnID] = *cc + _ = rs.ac.SendWSHello(map[string]interface{}{ + "active_connections": len(rs.conns), + }) + rs.connm.Unlock() +} + +func (rs *RACServer) Start() error { + wg := sync.WaitGroup{} + wg.Add(2) + go func() { + defer wg.Done() + metrics.RunServer() + }() + go func() { + defer wg.Done() + err := rs.startGuac() + if err != nil { + panic(err) + } + }() + wg.Wait() + return nil +} + +func (rs *RACServer) Stop() error { + if rs.guacd != nil { + return rs.guacd.Process.Kill() + } + return nil +} + +func (rs *RACServer) TimerFlowCacheExpiry(context.Context) {} + +func (rs *RACServer) Type() string { + return "rac" +} + +func (rs *RACServer) Refresh() error { + return nil +} diff --git a/internal/web/static.go b/internal/web/static.go index c4f6dbcf4..8e0e3d0dd 100644 --- a/internal/web/static.go +++ b/internal/web/static.go @@ -34,6 +34,11 @@ func (ws *WebServer) configureStatic() { }) indexLessRouter.PathPrefix("/if/admin/assets").Handler(http.StripPrefix("/if/admin", distFs)) indexLessRouter.PathPrefix("/if/user/assets").Handler(http.StripPrefix("/if/user", distFs)) + indexLessRouter.PathPrefix("/if/rac/{app_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + web.DisableIndex(http.StripPrefix(fmt.Sprintf("/if/rac/%s", vars["app_slug"]), distFs)).ServeHTTP(rw, r) + }) indexLessRouter.PathPrefix("/media/").Handler(http.StripPrefix("/media", fs)) diff --git a/rac.Dockerfile b/rac.Dockerfile new file mode 100644 index 000000000..ecfd86688 --- /dev/null +++ b/rac.Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 + +# Stage 1: Build +FROM docker.io/golang:1.21.3-bookworm AS builder + +WORKDIR /go/src/goauthentik.io + +RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \ + --mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \ + --mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download + +ENV CGO_ENABLED=0 +COPY . . +RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ + --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ + go build -o /go/rac ./cmd/rac + +# Stage 2: Run +FROM ghcr.io/beryju/guacd:1.5.3 + +ARG GIT_BUILD_HASH +ENV GIT_BUILD_HASH=$GIT_BUILD_HASH + +LABEL org.opencontainers.image.url https://goauthentik.io +LABEL org.opencontainers.image.description goauthentik.io RAC outpost, see https://goauthentik.io for more info. +LABEL org.opencontainers.image.source https://github.com/goauthentik/authentik +LABEL org.opencontainers.image.version ${VERSION} +LABEL org.opencontainers.image.revision ${GIT_BUILD_HASH} + +COPY --from=builder /go/rac / + +HEALTHCHECK --interval=5s --retries=20 --start-period=3s CMD [ "/rac", "healthcheck" ] + +USER 1000 + +ENTRYPOINT ["/rac"] diff --git a/schema.yml b/schema.yml index d63b0c135..0ea6e8ee0 100644 --- a/schema.yml +++ b/schema.yml @@ -13953,6 +13953,279 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /propertymappings/rac/: + get: + operationId: propertymappings_rac_list + description: RACPropertyMapping Viewset + parameters: + - in: query + name: managed + schema: + type: string + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedRACPropertyMappingList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: propertymappings_rac_create + description: RACPropertyMapping Viewset + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RACPropertyMappingRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/RACPropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/rac/{pm_uuid}/: + get: + operationId: propertymappings_rac_retrieve + description: RACPropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RACPropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: propertymappings_rac_update + description: RACPropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Property Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RACPropertyMappingRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RACPropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: propertymappings_rac_partial_update + description: RACPropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Property Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedRACPropertyMappingRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RACPropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: propertymappings_rac_destroy + description: RACPropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/rac/{pm_uuid}/used_by/: + get: + operationId: propertymappings_rac_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /propertymappings/saml/: get: operationId: propertymappings_saml_list @@ -16060,6 +16333,274 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /providers/rac/: + get: + operationId: providers_rac_list + description: RACProvider Viewset + parameters: + - in: query + name: application__isnull + schema: + type: boolean + - in: query + name: name__iexact + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedRACProviderList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: providers_rac_create + description: RACProvider Viewset + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RACProviderRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/RACProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /providers/rac/{id}/: + get: + operationId: providers_rac_retrieve + description: RACProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this RAC Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RACProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: providers_rac_update + description: RACProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this RAC Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RACProviderRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RACProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: providers_rac_partial_update + description: RACProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this RAC Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedRACProviderRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RACProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: providers_rac_destroy + description: RACProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this RAC Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /providers/rac/{id}/used_by/: + get: + operationId: providers_rac_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this RAC Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /providers/radius/: get: operationId: providers_radius_list @@ -17131,6 +17672,277 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /rac/endpoints/: + get: + operationId: rac_endpoints_list + description: List accessible endpoints + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: provider + schema: + type: integer + - in: query + name: search + schema: + type: string + - in: query + name: superuser_full_list + schema: + type: boolean + tags: + - rac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEndpointList' + description: '' + '400': + description: Bad request + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: rac_endpoints_create + description: Endpoint Viewset + tags: + - rac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EndpointRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Endpoint' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rac/endpoints/{pbm_uuid}/: + get: + operationId: rac_endpoints_retrieve + description: Endpoint Viewset + parameters: + - in: path + name: pbm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Endpoint. + required: true + tags: + - rac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Endpoint' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: rac_endpoints_update + description: Endpoint Viewset + parameters: + - in: path + name: pbm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Endpoint. + required: true + tags: + - rac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EndpointRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Endpoint' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: rac_endpoints_partial_update + description: Endpoint Viewset + parameters: + - in: path + name: pbm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Endpoint. + required: true + tags: + - rac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedEndpointRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Endpoint' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: rac_endpoints_destroy + description: Endpoint Viewset + parameters: + - in: path + name: pbm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Endpoint. + required: true + tags: + - rac + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rac/endpoints/{pbm_uuid}/used_by/: + get: + operationId: rac_endpoints_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: pbm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this RAC Endpoint. + required: true + tags: + - rac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /rbac/permissions/: get: operationId: rbac_permissions_list @@ -17279,6 +18091,9 @@ paths: - authentik_providers_oauth2.refreshtoken - authentik_providers_oauth2.scopemapping - authentik_providers_proxy.proxyprovider + - authentik_providers_rac.endpoint + - authentik_providers_rac.racpropertymapping + - authentik_providers_rac.racprovider - authentik_providers_radius.radiusprovider - authentik_providers_saml.samlpropertymapping - authentik_providers_saml.samlprovider @@ -17396,6 +18211,9 @@ paths: * `authentik_core.application` - Application * `authentik_core.token` - Token * `authentik_enterprise.license` - License + * `authentik_providers_rac.racprovider` - RAC Provider + * `authentik_providers_rac.endpoint` - RAC Endpoint + * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping required: true - in: query name: object_pk @@ -17567,6 +18385,9 @@ paths: - authentik_providers_oauth2.refreshtoken - authentik_providers_oauth2.scopemapping - authentik_providers_proxy.proxyprovider + - authentik_providers_rac.endpoint + - authentik_providers_rac.racpropertymapping + - authentik_providers_rac.racprovider - authentik_providers_radius.radiusprovider - authentik_providers_saml.samlpropertymapping - authentik_providers_saml.samlprovider @@ -17684,6 +18505,9 @@ paths: * `authentik_core.application` - Application * `authentik_core.token` - Token * `authentik_enterprise.license` - License + * `authentik_providers_rac.racprovider` - RAC Provider + * `authentik_providers_rac.endpoint` - RAC Endpoint + * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping required: true - in: query name: object_pk @@ -18334,7 +19158,6 @@ paths: - tr - tt - udm - - ug - uk - ur - uz @@ -27946,6 +28769,7 @@ components: - authentik.blueprints - authentik.core - authentik.enterprise + - authentik.enterprise.providers.rac type: string description: |- * `authentik.admin` - authentik Admin @@ -27997,6 +28821,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC AppleChallengeResponseRequest: type: object description: Pseudo class for plex response @@ -28142,6 +28967,14 @@ components: required: - name - slug + AuthModeEnum: + enum: + - static + - prompt + type: string + description: |- + * `static` - Static + * `prompt` - Prompt AuthTypeEnum: enum: - basic @@ -30511,6 +31344,79 @@ components: description: Activate users upon completion of stage. required: - name + Endpoint: + type: object + description: Endpoint Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Pbm uuid + name: + type: string + provider: + type: integer + provider_obj: + allOf: + - $ref: '#/components/schemas/RACProvider' + readOnly: true + protocol: + $ref: '#/components/schemas/ProtocolEnum' + host: + type: string + settings: {} + property_mappings: + type: array + items: + type: string + format: uuid + auth_mode: + $ref: '#/components/schemas/AuthModeEnum' + launch_url: + type: string + nullable: true + description: |- + Build actual launch URL (the provider itself does not have one, just + individual endpoints) + readOnly: true + required: + - auth_mode + - host + - launch_url + - name + - pk + - protocol + - provider + - provider_obj + EndpointRequest: + type: object + description: Endpoint Serializer + properties: + name: + type: string + minLength: 1 + provider: + type: integer + protocol: + $ref: '#/components/schemas/ProtocolEnum' + host: + type: string + minLength: 1 + settings: {} + property_mappings: + type: array + items: + type: string + format: uuid + auth_mode: + $ref: '#/components/schemas/AuthModeEnum' + required: + - auth_mode + - host + - name + - protocol + - provider ErrorDetail: type: object description: Serializer for rest_framework's error messages @@ -30767,6 +31673,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC model: allOf: - $ref: '#/components/schemas/ModelEnum' @@ -30848,6 +31755,9 @@ components: * `authentik_core.application` - Application * `authentik_core.token` - Token * `authentik_enterprise.license` - License + * `authentik_providers_rac.racprovider` - RAC Provider + * `authentik_providers_rac.endpoint` - RAC Endpoint + * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping required: - bound_to - component @@ -30963,6 +31873,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC model: allOf: - $ref: '#/components/schemas/ModelEnum' @@ -31044,6 +31955,9 @@ components: * `authentik_core.application` - Application * `authentik_core.token` - Token * `authentik_enterprise.license` - License + * `authentik_providers_rac.racprovider` - RAC Provider + * `authentik_providers_rac.endpoint` - RAC Endpoint + * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping required: - name EventRequest: @@ -33359,6 +34273,9 @@ components: - authentik_core.application - authentik_core.token - authentik_enterprise.license + - authentik_providers_rac.racprovider + - authentik_providers_rac.endpoint + - authentik_providers_rac.racpropertymapping type: string description: |- * `authentik_crypto.certificatekeypair` - Certificate-Key Pair @@ -33435,6 +34352,9 @@ components: * `authentik_core.application` - Application * `authentik_core.token` - Token * `authentik_enterprise.license` - License + * `authentik_providers_rac.racprovider` - RAC Provider + * `authentik_providers_rac.endpoint` - RAC Endpoint + * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping NameIdPolicyEnum: enum: - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress @@ -34421,11 +35341,13 @@ components: - proxy - ldap - radius + - rac type: string description: |- * `proxy` - Proxy * `ldap` - Ldap * `radius` - Radius + * `rac` - Rac PaginatedApplicationList: type: object properties: @@ -34642,6 +35564,18 @@ components: required: - pagination - results + PaginatedEndpointList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/Endpoint' + required: + - pagination + - results PaginatedEventList: type: object properties: @@ -35110,6 +36044,30 @@ components: required: - pagination - results + PaginatedRACPropertyMappingList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/RACPropertyMapping' + required: + - pagination + - results + PaginatedRACProviderList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/RACProvider' + required: + - pagination + - results PaginatedRadiusOutpostConfigList: type: object properties: @@ -36317,6 +37275,28 @@ components: activate_user_on_success: type: boolean description: Activate users upon completion of stage. + PatchedEndpointRequest: + type: object + description: Endpoint Serializer + properties: + name: + type: string + minLength: 1 + provider: + type: integer + protocol: + $ref: '#/components/schemas/ProtocolEnum' + host: + type: string + minLength: 1 + settings: {} + property_mappings: + type: array + items: + type: string + format: uuid + auth_mode: + $ref: '#/components/schemas/AuthModeEnum' PatchedEventMatcherPolicyRequest: type: object description: Event Matcher Policy Serializer @@ -36424,6 +37404,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC model: allOf: - $ref: '#/components/schemas/ModelEnum' @@ -36505,6 +37486,9 @@ components: * `authentik_core.application` - Application * `authentik_core.token` - Token * `authentik_enterprise.license` - License + * `authentik_providers_rac.racprovider` - RAC Provider + * `authentik_providers_rac.endpoint` - RAC Endpoint + * `authentik_providers_rac.racpropertymapping` - RAC Property Mapping PatchedEventRequest: type: object description: Event Serializer @@ -37631,6 +38615,55 @@ components: minLength: 1 description: 'Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).' + PatchedRACPropertyMappingRequest: + type: object + description: RACPropertyMapping Serializer + properties: + managed: + type: string + nullable: true + minLength: 1 + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + minLength: 1 + expression: + type: string + static_settings: + type: object + additionalProperties: {} + PatchedRACProviderRequest: + type: object + description: RACProvider Serializer + properties: + name: + type: string + minLength: 1 + authentication_flow: + type: string + format: uuid + nullable: true + description: Flow used for authentication when the associated application + is accessed by an un-authenticated user. + authorization_flow: + type: string + format: uuid + description: Flow used when authorizing this provider. + property_mappings: + type: array + items: + type: string + format: uuid + settings: {} + connection_expiry: + type: string + minLength: 1 + description: 'Determines how long a session lasts. Default of 0 means that + the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)' PatchedRadiusProviderRequest: type: object description: RadiusProvider Serializer @@ -39066,6 +40099,16 @@ components: required: - result - successful + ProtocolEnum: + enum: + - rdp + - vnc + - ssh + type: string + description: |- + * `rdp` - Rdp + * `vnc` - Vnc + * `ssh` - Ssh Provider: type: object description: Provider Serializer @@ -39148,6 +40191,7 @@ components: - authentik_providers_ldap.ldapprovider - authentik_providers_oauth2.oauth2provider - authentik_providers_proxy.proxyprovider + - authentik_providers_rac.racprovider - authentik_providers_radius.radiusprovider - authentik_providers_saml.samlprovider - authentik_providers_scim.scimprovider @@ -39156,6 +40200,7 @@ components: * `authentik_providers_ldap.ldapprovider` - authentik_providers_ldap.ldapprovider * `authentik_providers_oauth2.oauth2provider` - authentik_providers_oauth2.oauth2provider * `authentik_providers_proxy.proxyprovider` - authentik_providers_proxy.proxyprovider + * `authentik_providers_rac.racprovider` - authentik_providers_rac.racprovider * `authentik_providers_radius.radiusprovider` - authentik_providers_radius.radiusprovider * `authentik_providers_saml.samlprovider` - authentik_providers_saml.samlprovider * `authentik_providers_scim.scimprovider` - authentik_providers_scim.scimprovider @@ -39563,6 +40608,189 @@ components: - authorization_flow - external_host - name + RACPropertyMapping: + type: object + description: RACPropertyMapping Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Pm uuid + managed: + type: string + nullable: true + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + expression: + type: string + component: + type: string + description: Get object's component so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + static_settings: + type: object + additionalProperties: {} + required: + - component + - meta_model_name + - name + - pk + - static_settings + - verbose_name + - verbose_name_plural + RACPropertyMappingRequest: + type: object + description: RACPropertyMapping Serializer + properties: + managed: + type: string + nullable: true + minLength: 1 + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + minLength: 1 + expression: + type: string + static_settings: + type: object + additionalProperties: {} + required: + - name + - static_settings + RACProvider: + type: object + description: RACProvider Serializer + properties: + pk: + type: integer + readOnly: true + title: ID + name: + type: string + authentication_flow: + type: string + format: uuid + nullable: true + description: Flow used for authentication when the associated application + is accessed by an un-authenticated user. + authorization_flow: + type: string + format: uuid + description: Flow used when authorizing this provider. + property_mappings: + type: array + items: + type: string + format: uuid + component: + type: string + description: Get object component so that we know how to edit the object + readOnly: true + assigned_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_application_name: + type: string + description: Application's display Name. + readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + settings: {} + outpost_set: + type: array + items: + type: string + readOnly: true + connection_expiry: + type: string + description: 'Determines how long a session lasts. Default of 0 means that + the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)' + required: + - assigned_application_name + - assigned_application_slug + - assigned_backchannel_application_name + - assigned_backchannel_application_slug + - authorization_flow + - component + - meta_model_name + - name + - outpost_set + - pk + - verbose_name + - verbose_name_plural + RACProviderRequest: + type: object + description: RACProvider Serializer + properties: + name: + type: string + minLength: 1 + authentication_flow: + type: string + format: uuid + nullable: true + description: Flow used for authentication when the associated application + is accessed by an un-authenticated user. + authorization_flow: + type: string + format: uuid + description: Flow used when authorizing this provider. + property_mappings: + type: array + items: + type: string + format: uuid + settings: {} + connection_expiry: + type: string + minLength: 1 + description: 'Determines how long a session lasts. Default of 0 means that + the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)' + required: + - authorization_flow + - name RadiusOutpostConfig: type: object description: RadiusProvider Serializer @@ -42786,6 +44014,7 @@ components: - $ref: '#/components/schemas/LDAPProviderRequest' - $ref: '#/components/schemas/OAuth2ProviderRequest' - $ref: '#/components/schemas/ProxyProviderRequest' + - $ref: '#/components/schemas/RACProviderRequest' - $ref: '#/components/schemas/RadiusProviderRequest' - $ref: '#/components/schemas/SAMLProviderRequest' - $ref: '#/components/schemas/SCIMProviderRequest' @@ -42795,6 +44024,7 @@ components: authentik_providers_ldap.ldapprovider: '#/components/schemas/LDAPProviderRequest' authentik_providers_oauth2.oauth2provider: '#/components/schemas/OAuth2ProviderRequest' authentik_providers_proxy.proxyprovider: '#/components/schemas/ProxyProviderRequest' + authentik_providers_rac.racprovider: '#/components/schemas/RACProviderRequest' authentik_providers_radius.radiusprovider: '#/components/schemas/RadiusProviderRequest' authentik_providers_saml.samlprovider: '#/components/schemas/SAMLProviderRequest' authentik_providers_scim.scimprovider: '#/components/schemas/SCIMProviderRequest' diff --git a/web/package-lock.json b/web/package-lock.json index 59c543660..ac34f18b7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -35,6 +35,7 @@ "core-js": "^3.35.0", "country-flag-icons": "^1.5.9", "fuse.js": "^7.0.0", + "guacamole-common-js": "^1.5.0", "lit": "^2.8.0", "mermaid": "^10.6.1", "rapidoc": "^9.3.4", @@ -72,6 +73,7 @@ "@types/chart.js": "^2.9.41", "@types/codemirror": "5.60.15", "@types/grecaptcha": "^3.0.7", + "@types/guacamole-common-js": "1.3.2", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "babel-plugin-macros": "^3.1.0", @@ -7369,6 +7371,12 @@ "integrity": "sha512-ah5GDQfsiK3dnkaCbYcDFZXkZCG3o90VRu9hzXHnSe4kACrRB1KUI/ZyWHvYmqm1W5Tl8B5YxxT98uGTlkbf2Q==", "dev": true }, + "node_modules/@types/guacamole-common-js": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/guacamole-common-js/-/guacamole-common-js-1.3.2.tgz", + "integrity": "sha512-217AvsdGfuoqrXLWjrZOjO1CRzY0PNCG07NQf+cW6gYZhExCpjwDrpIbi5pFrmskPZB3T8n1CZLEoYW7rTERNQ==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", @@ -11966,6 +11974,11 @@ "node": ">=6.0" } }, + "node_modules/guacamole-common-js": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz", + "integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg==" + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", diff --git a/web/package.json b/web/package.json index f84bf97a8..f446eee57 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,7 @@ "core-js": "^3.35.0", "country-flag-icons": "^1.5.9", "fuse.js": "^7.0.0", + "guacamole-common-js": "^1.5.0", "lit": "^2.8.0", "mermaid": "^10.6.1", "rapidoc": "^9.3.4", @@ -97,6 +98,7 @@ "@types/chart.js": "^2.9.41", "@types/codemirror": "5.60.15", "@types/grecaptcha": "^3.0.7", + "@types/guacamole-common-js": "1.3.2", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "babel-plugin-macros": "^3.1.0", diff --git a/web/rollup.config.mjs b/web/rollup.config.mjs index 49825a29d..c4139e13e 100644 --- a/web/rollup.config.mjs +++ b/web/rollup.config.mjs @@ -129,6 +129,21 @@ export const standalone = ["api-browser", "loading"].map((input) => { }; }); +export const enterprise = ["rac"].map((input) => { + return { + input: `./src/enterprise/${input}`, + output: [ + { + format: "es", + dir: `dist/enterprise/${input}`, + sourcemap: true, + manualChunks: manualChunks, + }, + ], + ...defaultOptions, + }; +}); + export default [ POLY, // Standalone @@ -172,4 +187,6 @@ export default [ ], ...defaultOptions, }, + // Enterprise + ...enterprise, ]; diff --git a/web/src/admin/outposts/OutpostForm.ts b/web/src/admin/outposts/OutpostForm.ts index 1952cfe85..2c5ac9722 100644 --- a/web/src/admin/outposts/OutpostForm.ts +++ b/web/src/admin/outposts/OutpostForm.ts @@ -21,6 +21,7 @@ import { OutpostsServiceConnectionsAllListRequest, PaginatedLDAPProviderList, PaginatedProxyProviderList, + PaginatedRACProviderList, PaginatedRadiusProviderList, ProvidersApi, ServiceConnection, @@ -38,7 +39,8 @@ export class OutpostForm extends ModelForm { providers?: | PaginatedProxyProviderList | PaginatedLDAPProviderList - | PaginatedRadiusProviderList; + | PaginatedRadiusProviderList + | PaginatedRACProviderList; defaultConfig?: OutpostDefaultConfig; @@ -73,6 +75,12 @@ export class OutpostForm extends ModelForm { applicationIsnull: false, }); break; + case OutpostTypeEnum.Rac: + this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersRacList({ + ordering: "name", + applicationIsnull: false, + }); + break; case OutpostTypeEnum.UnknownDefaultOpenApi: this.providers = undefined; } @@ -133,6 +141,12 @@ export class OutpostForm extends ModelForm { > ${msg("Radius")} + diff --git a/web/src/admin/outposts/OutpostListPage.ts b/web/src/admin/outposts/OutpostListPage.ts index 390134ad0..318355585 100644 --- a/web/src/admin/outposts/OutpostListPage.ts +++ b/web/src/admin/outposts/OutpostListPage.ts @@ -41,6 +41,8 @@ export function TypeToLabel(type?: OutpostTypeEnum): string { return msg("LDAP"); case OutpostTypeEnum.Radius: return msg("Radius"); + case OutpostTypeEnum.Rac: + return msg("RAC"); case OutpostTypeEnum.UnknownDefaultOpenApi: return msg("Unknown type"); } diff --git a/web/src/admin/property-mappings/PropertyMappingListPage.ts b/web/src/admin/property-mappings/PropertyMappingListPage.ts index e961a744c..18521f5e4 100644 --- a/web/src/admin/property-mappings/PropertyMappingListPage.ts +++ b/web/src/admin/property-mappings/PropertyMappingListPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm"; import "@goauthentik/admin/property-mappings/PropertyMappingNotification"; +import "@goauthentik/admin/property-mappings/PropertyMappingRACForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm"; import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm"; diff --git a/web/src/admin/property-mappings/PropertyMappingRACForm.ts b/web/src/admin/property-mappings/PropertyMappingRACForm.ts new file mode 100644 index 000000000..72e2bb090 --- /dev/null +++ b/web/src/admin/property-mappings/PropertyMappingRACForm.ts @@ -0,0 +1,195 @@ +import { first } from "@goauthentik/app/common/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { docLink } from "@goauthentik/common/global"; +import "@goauthentik/elements/CodeMirror"; +import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { PropertymappingsApi, RACPropertyMapping } from "@goauthentik/api"; + +@customElement("ak-property-mapping-rac-form") +export class PropertyMappingLDAPForm extends ModelForm { + loadInstance(pk: string): Promise { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacRetrieve({ + pmUuid: pk, + }); + } + + getSuccessMessage(): string { + if (this.instance) { + return msg("Successfully updated mapping."); + } else { + return msg("Successfully created mapping."); + } + } + + async send(data: RACPropertyMapping): Promise { + if (this.instance) { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacUpdate({ + pmUuid: this.instance.pk || "", + rACPropertyMappingRequest: data, + }); + } else { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacCreate({ + rACPropertyMappingRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + + ${msg("General settings")} +
+ + + + + + +
+
+ + ${msg("RDP settings")} +
+ + + + + + + + + + + + +
+
+ + ${msg("Advanced settings")} +
+ + + +

+ ${msg("Expression using Python.")} + + ${msg("See documentation for a list of all variables.")} + +

+
+
+
+ `; + } +} diff --git a/web/src/admin/property-mappings/PropertyMappingWizard.ts b/web/src/admin/property-mappings/PropertyMappingWizard.ts index 9086546a0..4773dd93a 100644 --- a/web/src/admin/property-mappings/PropertyMappingWizard.ts +++ b/web/src/admin/property-mappings/PropertyMappingWizard.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm"; import "@goauthentik/admin/property-mappings/PropertyMappingNotification"; +import "@goauthentik/admin/property-mappings/PropertyMappingRACForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm"; import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm"; import "@goauthentik/admin/property-mappings/PropertyMappingTestForm"; diff --git a/web/src/admin/providers/ProviderListPage.ts b/web/src/admin/providers/ProviderListPage.ts index 6ff994611..87a123c82 100644 --- a/web/src/admin/providers/ProviderListPage.ts +++ b/web/src/admin/providers/ProviderListPage.ts @@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/ProviderWizard"; import "@goauthentik/admin/providers/ldap/LDAPProviderForm"; import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; import "@goauthentik/admin/providers/proxy/ProxyProviderForm"; +import "@goauthentik/admin/providers/rac/RACProviderForm"; import "@goauthentik/admin/providers/radius/RadiusProviderForm"; import "@goauthentik/admin/providers/saml/SAMLProviderForm"; import "@goauthentik/admin/providers/scim/SCIMProviderForm"; diff --git a/web/src/admin/providers/ProviderViewPage.ts b/web/src/admin/providers/ProviderViewPage.ts index 4157081d8..5cebd14dd 100644 --- a/web/src/admin/providers/ProviderViewPage.ts +++ b/web/src/admin/providers/ProviderViewPage.ts @@ -1,6 +1,7 @@ import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage"; import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage"; import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage"; +import "@goauthentik/admin/providers/rac/RACProviderViewPage"; import "@goauthentik/admin/providers/radius/RadiusProviderViewPage"; import "@goauthentik/admin/providers/saml/SAMLProviderViewPage"; import "@goauthentik/admin/providers/scim/SCIMProviderViewPage"; @@ -65,6 +66,10 @@ export class ProviderViewPage extends AKElement { return html``; + case "ak-provider-rac-form": + return html``; default: return html`

Invalid provider type ${this.provider?.component}

`; } diff --git a/web/src/admin/providers/rac/EndpointForm.ts b/web/src/admin/providers/rac/EndpointForm.ts new file mode 100644 index 000000000..af83af23f --- /dev/null +++ b/web/src/admin/providers/rac/EndpointForm.ts @@ -0,0 +1,146 @@ +import { first } from "@goauthentik/app/common/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import YAML from "yaml"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + AuthModeEnum, + Endpoint, + PaginatedRACPropertyMappingList, + PropertymappingsApi, + ProtocolEnum, + RacApi, +} from "@goauthentik/api"; + +@customElement("ak-rac-endpoint-form") +export class EndpointForm extends ModelForm { + @property({ type: Number }) + providerID?: number; + + propertyMappings?: PaginatedRACPropertyMappingList; + + async load(): Promise { + this.propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsRacList({ + ordering: "name", + }); + } + + loadInstance(pk: string): Promise { + return new RacApi(DEFAULT_CONFIG).racEndpointsRetrieve({ + pbmUuid: pk, + }); + } + + getSuccessMessage(): string { + return this.instance + ? msg("Successfully updated endpoint.") + : msg("Successfully created endpoint."); + } + + async send(data: Endpoint): Promise { + data.authMode = AuthModeEnum.Prompt; + if (!this.instance) { + data.provider = this.providerID || 0; + } else { + data.provider = this.instance.provider; + } + if (this.instance) { + return new RacApi(DEFAULT_CONFIG).racEndpointsPartialUpdate({ + pbmUuid: this.instance.pk || "", + patchedEndpointRequest: data, + }); + } else { + return new RacApi(DEFAULT_CONFIG).racEndpointsCreate({ + endpointRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + + + + + + +

${msg("Hostname/IP to connect to.")}

+
+ + +

+ ${msg("Hold control/command to select multiple items.")} +

+
+ + ${msg("Advanced settings")} +
+ + + +

${msg("Connection settings.")}

+
+
+
+ `; + } +} diff --git a/web/src/admin/providers/rac/EndpointList.ts b/web/src/admin/providers/rac/EndpointList.ts new file mode 100644 index 000000000..d3c3f88c3 --- /dev/null +++ b/web/src/admin/providers/rac/EndpointList.ts @@ -0,0 +1,142 @@ +import "@goauthentik/admin/policies/BoundPoliciesList"; +import "@goauthentik/app/admin/providers/rac/EndpointForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { uiConfig } from "@goauthentik/common/ui/config"; +import "@goauthentik/elements/buttons/SpinnerButton"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; +import { PaginatedResponse, Table } from "@goauthentik/elements/table/Table"; +import { TableColumn } from "@goauthentik/elements/table/Table"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; + +import { + Endpoint, + RACProvider, + RacApi, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; + +@customElement("ak-rac-endpoint-list") +export class EndpointListPage extends Table { + expandable = true; + checkbox = true; + + searchEnabled(): boolean { + return true; + } + + @property() + order = "name"; + + @property({ attribute: false }) + provider?: RACProvider; + + static get styles(): CSSResult[] { + return super.styles.concat(PFDescriptionList); + } + + async apiEndpoint(page: number): Promise> { + return new RacApi(DEFAULT_CONFIG).racEndpointsList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + provider: this.provider?.pk, + superuserFullList: true, + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn(msg("Name"), "name"), + new TableColumn(msg("Host"), "host"), + new TableColumn(msg("Actions")), + ]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return [ + { key: msg("Name"), value: item.name }, + { key: msg("Host"), value: item.host }, + ]; + }} + .usedBy=${(item: Endpoint) => { + return new RacApi(DEFAULT_CONFIG).racEndpointsUsedByList({ + pbmUuid: item.pk, + }); + }} + .delete=${(item: Endpoint) => { + return new RacApi(DEFAULT_CONFIG).racEndpointsDestroy({ + pbmUuid: item.pk, + }); + }} + > + + `; + } + + row(item: Endpoint): TemplateResult[] { + return [ + html`${item.name}`, + html`${item.host}`, + html` + ${msg("Update")} + ${msg("Update Endpoint")} + + + + + + `, + ]; + } + + renderExpanded(item: Endpoint): TemplateResult { + return html` + +
+
+

+ ${msg( + "These bindings control which users will have access to this endpoint. Users must also have access to the application.", + )} +

+ +
+
+ `; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Create")} + ${msg("Create Endpoint")} + + + + + `; + } +} diff --git a/web/src/admin/providers/rac/RACProviderForm.ts b/web/src/admin/providers/rac/RACProviderForm.ts new file mode 100644 index 000000000..53a5357a9 --- /dev/null +++ b/web/src/admin/providers/rac/RACProviderForm.ts @@ -0,0 +1,158 @@ +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search"; +import { first } from "@goauthentik/app/common/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/utils/TimeDeltaHelp"; +import YAML from "yaml"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + FlowsInstancesListDesignationEnum, + PaginatedEndpointList, + PaginatedRACPropertyMappingList, + PropertymappingsApi, + ProvidersApi, + RACProvider, + RacApi, +} from "@goauthentik/api"; + +@customElement("ak-provider-rac-form") +export class RACProviderFormPage extends ModelForm { + @state() + endpoints?: PaginatedEndpointList; + + propertyMappings?: PaginatedRACPropertyMappingList; + + async load(): Promise { + this.endpoints = await new RacApi(DEFAULT_CONFIG).racEndpointsList({}); + this.propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsRacList({ + ordering: "name", + }); + } + + async loadInstance(pk: number): Promise { + return new ProvidersApi(DEFAULT_CONFIG).providersRacRetrieve({ + id: pk, + }); + } + + getSuccessMessage(): string { + if (this.instance) { + return msg("Successfully updated provider."); + } else { + return msg("Successfully created provider."); + } + } + + async send(data: RACProvider): Promise { + if (this.instance) { + return new ProvidersApi(DEFAULT_CONFIG).providersRacUpdate({ + id: this.instance.pk || 0, + rACProviderRequest: data, + }); + } else { + return new ProvidersApi(DEFAULT_CONFIG).providersRacCreate({ + rACProviderRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + + + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +

+ ${msg( + "Determines how long a session lasts before being disconnected and requiring re-authorization.", + )} +

+ +
+ + + ${msg("Protocol settings")} +
+ + +

+ ${msg("Hold control/command to select multiple items.")} +

+
+ + + +

${msg("Connection settings.")}

+
+
+
+ `; + } +} diff --git a/web/src/admin/providers/rac/RACProviderViewPage.ts b/web/src/admin/providers/rac/RACProviderViewPage.ts new file mode 100644 index 000000000..393fa4375 --- /dev/null +++ b/web/src/admin/providers/rac/RACProviderViewPage.ts @@ -0,0 +1,181 @@ +import "@goauthentik/admin/providers/RelatedApplicationButton"; +import "@goauthentik/admin/providers/rac/EndpointForm"; +import "@goauthentik/admin/providers/rac/EndpointList"; +import "@goauthentik/admin/providers/rac/RACProviderForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import "@goauthentik/components/ak-status-label"; +import "@goauthentik/components/events/ObjectChangelog"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/Tabs"; +import "@goauthentik/elements/buttons/ModalButton"; +import "@goauthentik/elements/buttons/SpinnerButton"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { + ProvidersApi, + RACProvider, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; + +@customElement("ak-provider-rac-view") +export class RACProviderViewPage extends AKElement { + @property() + set args(value: { [key: string]: number }) { + this.providerID = value.id; + } + + @property({ type: Number }) + set providerID(value: number) { + new ProvidersApi(DEFAULT_CONFIG) + .providersRacRetrieve({ + id: value, + }) + .then((prov) => (this.provider = prov)); + } + + @property({ attribute: false }) + provider?: RACProvider; + + static get styles(): CSSResult[] { + return [ + PFBase, + PFButton, + PFPage, + PFGrid, + PFContent, + PFList, + PFForm, + PFFormControl, + PFCard, + PFDescriptionList, + PFBanner, + ]; + } + + constructor() { + super(); + this.addEventListener(EVENT_REFRESH, () => { + if (!this.provider?.pk) return; + this.providerID = this.provider?.pk; + }); + } + + render(): TemplateResult { + if (!this.provider) { + return html``; + } + return html` +
+ ${this.renderTabOverview()} +
+
+
+
+ + +
+
+
+ +
`; + } + + renderTabOverview(): TemplateResult { + if (!this.provider) { + return html``; + } + return html`
+ ${msg("RAC is in preview.")} + ${msg("Send us feedback!")} +
+ ${this.provider?.assignedApplicationName + ? html`` + : html`
+ ${msg("Warning: Provider is not used by an Application.")} +
`} + ${this.provider?.outpostSet.length < 1 + ? html`
+ ${msg("Warning: Provider is not used by any Outpost.")} +
` + : html``} +
+
+
+
+
+
+ ${msg("Name")} +
+
+
+ ${this.provider.name} +
+
+
+
+
+ ${msg("Assigned to application")} +
+
+
+ +
+
+
+
+
+ +
+
+
${msg("Endpoints")}
+
+ +
+
+
`; + } +} diff --git a/web/src/components/events/ObjectChangelog.ts b/web/src/components/events/ObjectChangelog.ts index 160a98d73..dcfef105b 100644 --- a/web/src/components/events/ObjectChangelog.ts +++ b/web/src/components/events/ObjectChangelog.ts @@ -1,3 +1,5 @@ +import { EventGeo } from "@goauthentik/app/admin/events/utils"; +import { actionToLabel } from "@goauthentik/app/common/labels"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { uiConfig } from "@goauthentik/common/ui/config"; @@ -73,7 +75,7 @@ export class ObjectChangelog extends Table { row(item: EventWithContext): TemplateResult[] { return [ - html`${item.action}`, + html`${actionToLabel(item.action)}`, html`
${item.user?.username}
${item.user.on_behalf_of ? html` @@ -81,7 +83,9 @@ export class ObjectChangelog extends Table { ` : html``}`, html`${item.created?.toLocaleString()}`, - html`${item.clientIp || msg("-")}`, + html`
${item.clientIp || msg("-")}
+ + ${EventGeo(item)}`, ]; } diff --git a/web/src/elements/LoadingOverlay.ts b/web/src/elements/LoadingOverlay.ts index 25ed89667..8420156df 100644 --- a/web/src/elements/LoadingOverlay.ts +++ b/web/src/elements/LoadingOverlay.ts @@ -1,5 +1,5 @@ import { AKElement } from "@goauthentik/elements/Base"; -import { PFSize } from "@goauthentik/elements/Spinner"; +import "@goauthentik/elements/EmptyState"; import { CSSResult, TemplateResult, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -33,6 +33,8 @@ export class LoadingOverlay extends AKElement { } render(): TemplateResult { - return html``; + return html` + + `; } } diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 0b78c4dfb..82fb9f5ae 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -27,6 +27,11 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { Pagination, ResponseError } from "@goauthentik/api"; +export interface TableLike { + order?: string; + fetch: () => void; +} + export class TableColumn { title: string; orderBy?: string; @@ -38,7 +43,7 @@ export class TableColumn { this.orderBy = orderBy; } - headerClickHandler(table: Table): void { + headerClickHandler(table: TableLike): void { if (!this.orderBy) { return; } @@ -46,7 +51,7 @@ export class TableColumn { table.fetch(); } - private getSortIndicator(table: Table): string { + private getSortIndicator(table: TableLike): string { switch (table.order) { case this.orderBy: return "fa-long-arrow-alt-down"; @@ -57,7 +62,7 @@ export class TableColumn { } } - renderSortable(table: Table): TemplateResult { + renderSortable(table: TableLike): TemplateResult { return html` `; } - render(table: Table): TemplateResult { + render(table: TableLike): TemplateResult { const classes = { "pf-c-table__sort": !!this.orderBy, "pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`, @@ -89,7 +94,7 @@ export interface PaginatedResponse { results: Array; } -export abstract class Table extends AKElement { +export abstract class Table extends AKElement implements TableLike { abstract apiEndpoint(page: number): Promise>; abstract columns(): TableColumn[]; abstract row(item: T): TemplateResult[]; @@ -123,6 +128,12 @@ export abstract class Table extends AKElement { @property({ type: Boolean }) checkbox = false; + @property({ type: Boolean }) + clickable = false; + + @property({ attribute: false }) + clickHandler: (item: T) => void = () => {}; + @property({ type: Boolean }) radioSelect = false; @@ -356,8 +367,12 @@ export abstract class Table extends AKElement { return html` { + this.clickHandler(item); + } + : itemSelectHandler} > ${this.checkbox ? renderCheckbox() : html``} ${this.expandable ? renderExpansion() : html``} diff --git a/web/src/elements/table/TableModal.ts b/web/src/elements/table/TableModal.ts index 328f5ffdf..341951fe6 100644 --- a/web/src/elements/table/TableModal.ts +++ b/web/src/elements/table/TableModal.ts @@ -19,7 +19,18 @@ export abstract class TableModal extends Table { size: PFSize = PFSize.Large; @property({ type: Boolean }) - open = false; + set open(value: boolean) { + this._open = value; + if (value) { + this.fetch(); + } + } + + get open(): boolean { + return this._open; + } + + _open = false; static get styles(): CSSResult[] { return super.styles.concat( @@ -43,6 +54,13 @@ export abstract class TableModal extends Table { }); } + public async fetch(): Promise { + if (!this.open) { + return; + } + return super.fetch(); + } + resetForms(): void { this.querySelectorAll("[slot=form]").forEach((form) => { if ("resetForm" in form) { diff --git a/web/src/enterprise/rac/index.ts b/web/src/enterprise/rac/index.ts new file mode 100644 index 000000000..495e09801 --- /dev/null +++ b/web/src/enterprise/rac/index.ts @@ -0,0 +1,324 @@ +import { TITLE_DEFAULT } from "@goauthentik/app/common/constants"; +import { Interface } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/LoadingOverlay"; +import Guacamole from "guacamole-common-js"; + +import { msg, str } from "@lit/localize"; +import { CSSResult, TemplateResult, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import AKGlobal from "@goauthentik/common/styles/authentik.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +enum GuacClientState { + IDLE = 0, + CONNECTING = 1, + WAITING = 2, + CONNECTED = 3, + DISCONNECTING = 4, + DISCONNECTED = 5, +} + +const AUDIO_INPUT_MIMETYPE = "audio/L16;rate=44100,channels=2"; +const RECONNECT_ATTEMPTS_INITIAL = 5; +const RECONNECT_ATTEMPTS = 5; + +@customElement("ak-rac") +export class RacInterface extends Interface { + static get styles(): CSSResult[] { + return [ + PFBase, + PFPage, + PFContent, + AKGlobal, + css` + :host { + cursor: none; + } + canvas { + z-index: unset !important; + } + .container { + overflow: hidden; + height: 100vh; + background-color: black; + display: flex; + justify-content: center; + align-items: center; + } + ak-loading-overlay { + z-index: 5; + } + `, + ]; + } + + client?: Guacamole.Client; + tunnel?: Guacamole.Tunnel; + + @state() + container?: HTMLElement; + + @state() + clientState?: GuacClientState; + + @state() + reconnectingMessage = ""; + + @property() + token?: string; + + @property() + endpointName?: string; + + @state() + clipboardWatcherTimer = 0; + + _previousClipboardValue: unknown; + + // Set to `true` if we've successfully connected once + hasConnected = false; + // Keep track of current connection attempt + connectionAttempt = 0; + + static domSize(): DOMRect { + return document.body.getBoundingClientRect(); + } + + constructor() { + super(); + this.initKeyboard(); + this.checkClipboard(); + this.clipboardWatcherTimer = setInterval( + this.checkClipboard.bind(this), + 500, + ) as unknown as number; + } + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener( + "focus", + () => { + this.checkClipboard(); + }, + { + capture: false, + }, + ); + window.addEventListener("resize", () => { + this.client?.sendSize( + Math.floor(RacInterface.domSize().width), + Math.floor(RacInterface.domSize().height), + ); + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + clearInterval(this.clipboardWatcherTimer); + } + + async firstUpdated(): Promise { + this.updateTitle(); + const wsUrl = `${window.location.protocol.replace("http", "ws")}//${ + window.location.host + }/ws/rac/${this.token}/`; + this.tunnel = new Guacamole.WebSocketTunnel(wsUrl); + this.tunnel.receiveTimeout = 10 * 1000; // 10 seconds + this.tunnel.onerror = (status) => { + console.debug("authentik/rac: tunnel error: ", status); + this.reconnect(); + }; + this.client = new Guacamole.Client(this.tunnel); + this.client.onerror = (err) => { + console.debug("authentik/rac: error: ", err); + this.reconnect(); + }; + this.client.onstatechange = (state) => { + this.clientState = state; + if (state === GuacClientState.CONNECTED) { + this.onConnected(); + } + }; + this.client.onclipboard = (stream, mimetype) => { + // If the received data is text, read it as a simple string + if (/^text\//.exec(mimetype)) { + const reader = new Guacamole.StringReader(stream); + let data = ""; + reader.ontext = (text) => { + data += text; + }; + reader.onend = () => { + this._previousClipboardValue = data; + navigator.clipboard.writeText(data); + }; + } else { + const reader = new Guacamole.BlobReader(stream, mimetype); + reader.onend = () => { + const blob = reader.getBlob(); + navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob, + }), + ]); + }; + } + console.debug("authentik/rac: updated clipboard from remote"); + }; + const params = new URLSearchParams(); + params.set("screen_width", Math.floor(RacInterface.domSize().width).toString()); + params.set("screen_height", Math.floor(RacInterface.domSize().height).toString()); + params.set("screen_dpi", (window.devicePixelRatio * 96).toString()); + this.client.connect(params.toString()); + } + + reconnect(): void { + this.clientState = undefined; + this.connectionAttempt += 1; + if (!this.hasConnected) { + // Check connection attempts if we haven't had a successful connection + if (this.connectionAttempt >= RECONNECT_ATTEMPTS_INITIAL) { + this.hasConnected = true; + this.reconnectingMessage = msg( + str`Connection failed after ${this.connectionAttempt} attempts.`, + ); + return; + } + } else { + if (this.connectionAttempt >= RECONNECT_ATTEMPTS) { + this.reconnectingMessage = msg( + str`Connection failed after ${this.connectionAttempt} attempts.`, + ); + return; + } + } + const delay = 500 * this.connectionAttempt; + this.reconnectingMessage = msg( + str`Re-connecting in ${Math.max(1, delay / 1000)} second(s).`, + ); + setTimeout(() => { + this.firstUpdated(); + }, delay); + } + + updateTitle(): void { + let title = this.tenant?.brandingTitle || TITLE_DEFAULT; + if (this.endpointName) { + title = `${this.endpointName} - ${title}`; + } + document.title = `${title}`; + } + + onConnected(): void { + console.debug("authentik/rac: connected"); + if (!this.client) { + return; + } + this.hasConnected = true; + this.container = this.client.getDisplay().getElement(); + this.initMouse(this.container); + this.client?.sendSize( + Math.floor(RacInterface.domSize().width), + Math.floor(RacInterface.domSize().height), + ); + } + + initMouse(container: HTMLElement): void { + const mouse = new Guacamole.Mouse(container); + const handler = (mouseState: Guacamole.Mouse.State, scaleMouse = false) => { + if (!this.client) return; + + if (scaleMouse) { + mouseState.y = mouseState.y / this.client.getDisplay().getScale(); + mouseState.x = mouseState.x / this.client.getDisplay().getScale(); + } + + this.client.sendMouseState(mouseState); + }; + mouse.onmouseup = mouse.onmousedown = (mouseState) => { + this.container?.focus(); + handler(mouseState); + }; + mouse.onmousemove = (mouseState) => { + handler(mouseState, true); + }; + } + + initAudioInput(): void { + const stream = this.client?.createAudioStream(AUDIO_INPUT_MIMETYPE); + if (!stream) return; + // Guacamole.AudioPlayer + const recorder = Guacamole.AudioRecorder.getInstance(stream, AUDIO_INPUT_MIMETYPE); + // If creation of the AudioRecorder failed, simply end the stream + if (!recorder) { + stream.sendEnd(); + return; + } + // Otherwise, ensure that another audio stream is created after this + // audio stream is closed + recorder.onclose = this.initAudioInput.bind(this); + } + + initKeyboard(): void { + const keyboard = new Guacamole.Keyboard(document); + keyboard.onkeydown = (keysym) => { + this.client?.sendKeyEvent(1, keysym); + }; + keyboard.onkeyup = (keysym) => { + this.client?.sendKeyEvent(0, keysym); + }; + } + + async checkClipboard(): Promise { + try { + if (!this._previousClipboardValue) { + this._previousClipboardValue = await navigator.clipboard.readText(); + return; + } + const newValue = await navigator.clipboard.readText(); + if (newValue !== this._previousClipboardValue) { + console.debug(`authentik/rac: new clipboard value: ${newValue}`); + this._previousClipboardValue = newValue; + this.writeClipboard(newValue); + } + } catch (ex) { + // The error is most likely caused by the document not being in focus + // in which case we can ignore it and just retry + if (ex instanceof DOMException) { + return; + } + console.warn("authentik/rac: error reading clipboard", ex); + } + } + + private writeClipboard(value: string) { + if (!this.client) { + return; + } + const stream = this.client.createClipboardStream("text/plain", "clipboard"); + const writer = new Guacamole.StringWriter(stream); + writer.sendText(value); + writer.sendEnd(); + console.debug("authentik/rac: Sent clipboard"); + } + + render(): TemplateResult { + return html` + ${this.clientState !== GuacClientState.CONNECTED + ? html` + + + ${this.hasConnected + ? html`${this.reconnectingMessage}` + : html`${msg("Connecting...")}`} + + + ` + : html``} +
${this.container}
+ `; + } +} diff --git a/web/src/user/LibraryApplication/RACLaunchEndpointModal.ts b/web/src/user/LibraryApplication/RACLaunchEndpointModal.ts new file mode 100644 index 000000000..40f5668f7 --- /dev/null +++ b/web/src/user/LibraryApplication/RACLaunchEndpointModal.ts @@ -0,0 +1,71 @@ +import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config"; +import { PaginatedResponse, TableColumn } from "@goauthentik/app/elements/table/Table"; +import { TableModal } from "@goauthentik/app/elements/table/TableModal"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { Application, Endpoint, RacApi } from "@goauthentik/api"; + +@customElement("ak-library-rac-endpoint-launch") +export class RACLaunchEndpointModal extends TableModal { + clickable = true; + searchEnabled(): boolean { + return true; + } + + clickHandler = (item: Endpoint) => { + if (!item.launchUrl) { + return; + } + if (this.app?.openInNewTab) { + window.open(item.launchUrl); + } else { + window.location.assign(item.launchUrl); + } + }; + + @property({ attribute: false }) + app?: Application; + + async apiEndpoint(page: number): Promise> { + const endpoints = await new RacApi(DEFAULT_CONFIG).racEndpointsList({ + provider: this.app?.provider || 0, + page: page, + search: this.search, + }); + if (this.open && endpoints.pagination.count === 1) { + this.clickHandler(endpoints.results[0]); + this.open = false; + } + return endpoints; + } + + columns(): TableColumn[] { + return [new TableColumn("Name")]; + } + + row(item: Endpoint): TemplateResult[] { + return [html`${item.name}`]; + } + + renderModalInner(): TemplateResult { + return html`
+
+

${msg("Select endpoint to connect to")}

+
+
+
${this.renderTable()}
+
+ { + this.open = false; + }} + class="pf-m-secondary" + > + ${msg("Cancel")} + +
`; + } +} diff --git a/web/src/user/LibraryApplication/index.ts b/web/src/user/LibraryApplication/index.ts index 282ce63b2..35f60804f 100644 --- a/web/src/user/LibraryApplication/index.ts +++ b/web/src/user/LibraryApplication/index.ts @@ -3,6 +3,7 @@ import { truncateWords } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-app-icon"; import { AKElement, rootInterface } from "@goauthentik/elements/Base"; import "@goauthentik/elements/Expand"; +import "@goauthentik/user/LibraryApplication/RACLaunchEndpointModal"; import { UserInterface } from "@goauthentik/user/UserInterface"; import { msg } from "@lit/localize"; @@ -85,6 +86,22 @@ export class LibraryApplication extends AKElement { `; } + renderLaunch(): TemplateResult { + if (!this.application) { + return html``; + } + if (this.application?.launchUrl === "goauthentik.io://providers/rac/launch") { + return html` + ${this.application.name} + `; + } + return html`${this.application.name}`; + } + render(): TemplateResult { if (!this.application) { return html``; @@ -111,13 +128,7 @@ export class LibraryApplication extends AKElement { - +
${this.renderLaunch()}
${expandable ? this.renderExpansion(this.application) : nothing} `; diff --git a/web/src/user/LibraryPage/LibraryPageImpl.utils.ts b/web/src/user/LibraryPage/LibraryPageImpl.utils.ts index bac9186e8..0b3375dc3 100644 --- a/web/src/user/LibraryPage/LibraryPageImpl.utils.ts +++ b/web/src/user/LibraryPage/LibraryPageImpl.utils.ts @@ -2,10 +2,16 @@ import type { Application } from "@goauthentik/api"; const isFullUrlRe = new RegExp("://"); const isHttpRe = new RegExp("http(s?)://"); +const isAuthentikSpecialRe = new RegExp("goauthentik.io://"); const isNotFullUrl = (url: string) => !isFullUrlRe.test(url); const isHttp = (url: string) => isHttpRe.test(url); +const isAuthentikSpecial = (url: string) => isAuthentikSpecialRe.test(url); export const appHasLaunchUrl = (app: Application) => { const url = app.launchUrl; - return !!(typeof url === "string" && url !== "" && (isHttp(url) || isNotFullUrl(url))); + return !!( + typeof url === "string" && + url !== "" && + (isHttp(url) || isNotFullUrl(url) || isAuthentikSpecial(url)) + ); }; diff --git a/web/xliff/de.xlf b/web/xliff/de.xlf index 68164cbb3..8cac9d9de 100644 --- a/web/xliff/de.xlf +++ b/web/xliff/de.xlf @@ -6117,6 +6117,126 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. diff --git a/web/xliff/en.xlf b/web/xliff/en.xlf index 29f454e9b..aa28b7c6a 100644 --- a/web/xliff/en.xlf +++ b/web/xliff/en.xlf @@ -6393,6 +6393,126 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index 3d45d2983..cbe16ba84 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -6033,6 +6033,126 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. diff --git a/web/xliff/fr.xlf b/web/xliff/fr.xlf index 63b478d2a..f808bbde3 100644 --- a/web/xliff/fr.xlf +++ b/web/xliff/fr.xlf @@ -1,4 +1,4 @@ - + @@ -613,9 +613,9 @@ Il y a jour(s) - The URL "" was not found. - L'URL " - " n'a pas été trouvée. + The URL "" was not found. + L'URL " + " n'a pas été trouvée. @@ -1057,8 +1057,8 @@ Il y a jour(s) - To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. - Pour permettre n'importe quelle URI de redirection, définissez cette valeur sur ".*". Soyez conscient des possibles implications de sécurité que cela peut avoir. + To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. + Pour permettre n'importe quelle URI de redirection, définissez cette valeur sur ".*". Soyez conscient des possibles implications de sécurité que cela peut avoir. @@ -1630,7 +1630,7 @@ Il y a jour(s) Token to authenticate with. Currently only bearer authentication is supported. - Jeton d'authentification à utiliser. Actuellement, seule l'authentification "bearer authentication" est prise en charge. + Jeton d'authentification à utiliser. Actuellement, seule l'authentification "bearer authentication" est prise en charge. @@ -1798,8 +1798,8 @@ Il y a jour(s) - Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". - Entrez une URL complète, un chemin relatif ou utilisez 'fa://fa-test' pour utiliser l'icône Font Awesome "fa-test". + Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". + Entrez une URL complète, un chemin relatif ou utilisez 'fa://fa-test' pour utiliser l'icône Font Awesome "fa-test". @@ -2892,7 +2892,7 @@ doesn't pass when either or both of the selected options are equal or above the To use SSL instead, use 'ldaps://' and disable this option. - Pour utiliser SSL à la base, utilisez "ldaps://" et désactviez cette option. + Pour utiliser SSL à la base, utilisez "ldaps://" et désactviez cette option. @@ -2981,8 +2981,8 @@ doesn't pass when either or both of the selected options are equal or above the - Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' - Champ qui contient les membres d'un groupe. Si vous utilisez le champ "memberUid", la valeur est censée contenir un nom distinctif relatif, par exemple 'memberUid=un-utilisateur' au lieu de 'memberUid=cn=un-utilisateur,ou=groups,...' + Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' + Champ qui contient les membres d'un groupe. Si vous utilisez le champ "memberUid", la valeur est censée contenir un nom distinctif relatif, par exemple 'memberUid=un-utilisateur' au lieu de 'memberUid=cn=un-utilisateur,ou=groups,...' @@ -3277,7 +3277,7 @@ doesn't pass when either or both of the selected options are equal or above the Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format 'transient', and the user doesn't log out manually. - Moment où les utilisateurs temporaires doivent être supprimés. Cela ne s'applique que si votre IDP utilise le format NameID "transient" et que l'utilisateur ne se déconnecte pas manuellement. + Moment où les utilisateurs temporaires doivent être supprimés. Cela ne s'applique que si votre IDP utilise le format NameID "transient" et que l'utilisateur ne se déconnecte pas manuellement. @@ -3445,7 +3445,7 @@ doesn't pass when either or both of the selected options are equal or above the Optionally set the 'FriendlyName' value of the Assertion attribute. - Indiquer la valeur "FriendlyName" de l'attribut d'assertion (optionnel) + Indiquer la valeur "FriendlyName" de l'attribut d'assertion (optionnel) @@ -3774,8 +3774,8 @@ doesn't pass when either or both of the selected options are equal or above the - When using an external logging solution for archiving, this can be set to "minutes=5". - En cas d'utilisation d'une solution de journalisation externe pour l'archivage, cette valeur peut être fixée à "minutes=5". + When using an external logging solution for archiving, this can be set to "minutes=5". + En cas d'utilisation d'une solution de journalisation externe pour l'archivage, cette valeur peut être fixée à "minutes=5". @@ -3784,8 +3784,8 @@ doesn't pass when either or both of the selected options are equal or above the - Format: "weeks=3;days=2;hours=3,seconds=2". - Format : "weeks=3;days=2;hours=3,seconds=2". + Format: "weeks=3;days=2;hours=3,seconds=2". + Format : "weeks=3;days=2;hours=3,seconds=2". @@ -3981,10 +3981,10 @@ doesn't pass when either or both of the selected options are equal or above the - Are you sure you want to update ""? + Are you sure you want to update ""? Êtes-vous sûr de vouloir mettre à jour - " - " ? + " + " ? @@ -5070,8 +5070,8 @@ doesn't pass when either or both of the selected options are equal or above the - A "roaming" authenticator, like a YubiKey - Un authentificateur "itinérant", comme une YubiKey + A "roaming" authenticator, like a YubiKey + Un authentificateur "itinérant", comme une YubiKey @@ -5396,7 +5396,7 @@ doesn't pass when either or both of the selected options are equal or above the Show arbitrary input fields to the user, for example during enrollment. Data is saved in the flow context under the 'prompt_data' variable. - Afficher des champs de saisie arbitraires à l'utilisateur, par exemple pendant l'inscription. Les données sont enregistrées dans le contexte du flux sous la variable "prompt_data". + Afficher des champs de saisie arbitraires à l'utilisateur, par exemple pendant l'inscription. Les données sont enregistrées dans le contexte du flux sous la variable "prompt_data". @@ -5405,10 +5405,10 @@ doesn't pass when either or both of the selected options are equal or above the - ("", of type ) + ("", of type ) - (" - ", de type + (" + ", de type ) @@ -5457,8 +5457,8 @@ doesn't pass when either or both of the selected options are equal or above the - If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. - Si défini à une durée supérieure à 0, l'utilisateur aura la possibilité de choisir de "rester connecté", ce qui prolongera sa session jusqu'à la durée spécifiée ici. + If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. + Si défini à une durée supérieure à 0, l'utilisateur aura la possibilité de choisir de "rester connecté", ce qui prolongera sa session jusqu'à la durée spécifiée ici. @@ -6242,7 +6242,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Can be in the format of 'unix://' when connecting to a local docker daemon, using 'ssh://' to connect via SSH, or 'https://:2376' when connecting to a remote system. - Peut être au format "unix://" pour une connexion à un service docker local, "ssh://" pour une connexion via SSH, ou "https://:2376" pour une connexion à un système distant. + Peut être au format "unix://" pour une connexion à un service docker local, "ssh://" pour une connexion via SSH, ou "https://:2376" pour une connexion à un système distant. @@ -7549,7 +7549,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Use this provider with nginx's auth_request or traefik's forwardAuth. Each application/domain needs its own provider. Additionally, on each domain, /outpost.goauthentik.io must be routed to the outpost (when using a managed outpost, this is done for you). - Utilisez ce fournisseur avec l'option "auth_request" de Nginx ou "forwardAuth" de Traefik. Chaque application/domaine a besoin de son propre fournisseur. De plus, sur chaque domaine, "/outpost.goauthentik.io" doit être routé vers le poste avancé (lorsque vous utilisez un poste avancé géré, cela est fait pour vous). + Utilisez ce fournisseur avec l'option "auth_request" de Nginx ou "forwardAuth" de Traefik. Chaque application/domaine a besoin de son propre fournisseur. De plus, sur chaque domaine, "/outpost.goauthentik.io" doit être routé vers le poste avancé (lorsque vous utilisez un poste avancé géré, cela est fait pour vous). Default relay state @@ -7963,7 +7963,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Utilisateur créé et ajouté au groupe avec succès - This user will be added to the group "". + This user will be added to the group "". Cet utilisateur sera ajouté au groupe &quot;&quot;. @@ -8041,7 +8041,127 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Require Outpost (flow can only be executed from an outpost). Forcer l'utilisation d'un avant-poste (le flux ne pourrait être exécuter que depuis un outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. - \ No newline at end of file + diff --git a/web/xliff/pl.xlf b/web/xliff/pl.xlf index 3dbed2422..b52ea863c 100644 --- a/web/xliff/pl.xlf +++ b/web/xliff/pl.xlf @@ -6241,6 +6241,126 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. diff --git a/web/xliff/pseudo-LOCALE.xlf b/web/xliff/pseudo-LOCALE.xlf index ecd85ea7c..bc883faa7 100644 --- a/web/xliff/pseudo-LOCALE.xlf +++ b/web/xliff/pseudo-LOCALE.xlf @@ -7979,4 +7979,124 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. + diff --git a/web/xliff/tr.xlf b/web/xliff/tr.xlf index 1b95f3f75..7b03127c0 100644 --- a/web/xliff/tr.xlf +++ b/web/xliff/tr.xlf @@ -6026,6 +6026,126 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. diff --git a/web/xliff/zh-Hans.xlf b/web/xliff/zh-Hans.xlf index 61687089d..b2a6e07d1 100644 --- a/web/xliff/zh-Hans.xlf +++ b/web/xliff/zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -613,9 +613,9 @@ - The URL "" was not found. - 未找到 URL " - "。 + The URL "" was not found. + 未找到 URL " + "。 @@ -1057,8 +1057,8 @@ - To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. - 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 + To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. + 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 @@ -1799,8 +1799,8 @@ - Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". - 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 + Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". + 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 @@ -2983,8 +2983,8 @@ doesn't pass when either or both of the selected options are equal or above the - Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' - 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' + Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' + 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' @@ -3776,8 +3776,8 @@ doesn't pass when either or both of the selected options are equal or above the - When using an external logging solution for archiving, this can be set to "minutes=5". - 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 + When using an external logging solution for archiving, this can be set to "minutes=5". + 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 @@ -3786,8 +3786,8 @@ doesn't pass when either or both of the selected options are equal or above the - Format: "weeks=3;days=2;hours=3,seconds=2". - 格式:"weeks=3;days=2;hours=3,seconds=2"。 + Format: "weeks=3;days=2;hours=3,seconds=2". + 格式:"weeks=3;days=2;hours=3,seconds=2"。 @@ -3983,10 +3983,10 @@ doesn't pass when either or both of the selected options are equal or above the - Are you sure you want to update ""? + Are you sure you want to update ""? 您确定要更新 - " - " 吗? + " + " 吗? @@ -5072,7 +5072,7 @@ doesn't pass when either or both of the selected options are equal or above the - A "roaming" authenticator, like a YubiKey + A "roaming" authenticator, like a YubiKey 像 YubiKey 这样的“漫游”身份验证器 @@ -5407,10 +5407,10 @@ doesn't pass when either or both of the selected options are equal or above the - ("", of type ) + ("", of type ) - (" - ",类型为 + (" + ",类型为 @@ -5459,7 +5459,7 @@ doesn't pass when either or both of the selected options are equal or above the - If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. + If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. 如果设置时长大于 0,用户可以选择“保持登录”选项,这将使用户的会话延长此处设置的时间。 @@ -7965,7 +7965,7 @@ Bindings to groups/users are checked against the user of the event. 成功创建用户并添加到组 - This user will be added to the group "". + This user will be added to the group "". 此用户将会被添加到组 &quot;&quot;。 @@ -8043,7 +8043,127 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). 需要前哨(流程只能从前哨执行)。 + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. - \ No newline at end of file + diff --git a/web/xliff/zh-Hant.xlf b/web/xliff/zh-Hant.xlf index ff31e2854..65794b706 100644 --- a/web/xliff/zh-Hant.xlf +++ b/web/xliff/zh-Hant.xlf @@ -6074,6 +6074,126 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. diff --git a/web/xliff/zh_TW.xlf b/web/xliff/zh_TW.xlf index 66c5d3228..9a9b690bd 100644 --- a/web/xliff/zh_TW.xlf +++ b/web/xliff/zh_TW.xlf @@ -7963,6 +7963,126 @@ Bindings to groups/users are checked against the user of the event. Require Outpost (flow can only be executed from an outpost). + + + Connection settings. + + + Successfully updated endpoint. + + + Successfully created endpoint. + + + Protocol + + + RDP + + + SSH + + + VNC + + + Host + + + Hostname/IP to connect to. + + + Endpoint(s) + + + Update Endpoint + + + These bindings control which users will have access to this endpoint. Users must also have access to the application. + + + Create Endpoint + + + RAC is in preview. + + + Update RAC Provider + + + Endpoints + + + General settings + + + RDP settings + + + Ignore server certificate + + + Enable wallpaper + + + Enable font-smoothing + + + Enable full window dragging + + + Network binding + + + No binding + + + Bind ASN + + + Bind ASN and Network + + + Bind ASN, Network and IP + + + Configure if sessions created by this stage should be bound to the Networks they were created in. + + + GeoIP binding + + + Bind Continent + + + Bind Continent and Country + + + Bind Continent, Country and City + + + Configure if sessions created by this stage should be bound to their GeoIP-based location + + + RAC + + + Connection failed after attempts. + + + Re-connecting in second(s). + + + Connecting... + + + Select endpoint to connect to + + + Connection expiry + + + Determines how long a session lasts before being disconnected and requiring re-authorization. diff --git a/website/docs/outposts/index.mdx b/website/docs/outposts/index.mdx index bd3d7744c..b5ffe1c25 100644 --- a/website/docs/outposts/index.mdx +++ b/website/docs/outposts/index.mdx @@ -7,6 +7,7 @@ An outpost is a single deployment of an authentik component, which can be deploy - [LDAP Provider](../providers/ldap/index.md) - [Proxy Provider](../providers/proxy/index.md) - [RADIUS Provider](../providers/radius/index.md) +- [RAC Provider](../providers/rac/index.md) ![](outposts.png) diff --git a/website/docs/providers/rac/index.md b/website/docs/providers/rac/index.md new file mode 100644 index 000000000..67e3b74da --- /dev/null +++ b/website/docs/providers/rac/index.md @@ -0,0 +1,47 @@ +--- +title: Remote Access (RAC) Provider +--- + +Enterprise + +--- + +:::info +This feature is in technical preview, so please report any Bugs you run into on [GitHub](https://github.com/goauthentik/authentik/issues) +::: + +The Remote access provider allows users to access Windows/macOS/Linux machines via [RDP](https://en.wikipedia.org/wiki/Remote_Desktop_Protocol)/[SSH](https://en.wikipedia.org/wiki/Secure_Shell)/[VNC](https://en.wikipedia.org/wiki/Virtual_Network_Computing). + +:::info +This provider requires the deployment of the [RAC Outpost](../../outposts/) +::: + +## Endpoints + +Unlike other providers, where one provider-application pair must be created for each resource you wish to access, the RAC provider handles this slightly differently. For each machine (computer/server) that should be accessible, an _Endpoint_ object must be created within an RAC provider. + +The _Endpoint_ object specifies the hostname/IP of the machine to connect to, as well as the protocol to use. Additionally it is possible to bind policies to _endpoint_ objects to restrict access. Users must have access to both the application the RAC Provider is using as well as the individual endpoint. + +Configuration like credentials can be specified through _settings_, which can be specified on different levels and are all merged together when connecting: + +1. Provider settings +2. Endpoint settings +3. Connection settings (see [Connections](#connections)) +4. Provider property mapping settings +5. Endpoint property mapping settings + +## Connections + +Each connection is authorized through the policies bound to the application and the endpoint, and additional verification can be done with the authorization flow. + +Additionally it is possible to modify the connection settings through the authorization flow. Configuration set in `connection_settings` in the flow plan context will be merged with other settings as shown above. + +A new connection is created every time an endpoint is selected in the [User Interface](../../interfaces/user/customization.mdx). Once the user's authentik session expires, the connection is terminated. Additionally, the connection timeout can be specified in the provider, which applies even if the user is still authenticated. The connection can also be terminated manually. + +## Capabilities + +The following features are currently supported: + +- Bi-directional clipboard +- Audio redirection (from remote machine to browser) +- Resizing diff --git a/website/docs/providers/radius/index.md b/website/docs/providers/radius/index.md index f9f0b5403..f7966ef2f 100644 --- a/website/docs/providers/radius/index.md +++ b/website/docs/providers/radius/index.md @@ -2,10 +2,6 @@ title: Radius Provider --- -:::info -This feature is still in technical preview, so please report any Bugs you run into on [GitHub](https://github.com/goauthentik/authentik/issues) -::: - You can configure a Radius Provider for applications that don't support any other protocols or require Radius. :::info diff --git a/website/sidebars.js b/website/sidebars.js index fe1b6725e..25c08ea1b 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -110,6 +110,7 @@ const docsSidebar = { items: ["providers/ldap/generic_setup"], }, "providers/scim/index", + "providers/rac/index", ], }, {