enterprise/providers: Add RAC [AUTH-15] (#7291)

* add basic guacamole

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make everything mostly work

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add rac build to CI

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix resize, fix web lint, sendSize correctly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* pre-send connection from client, format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve throughput

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework TokenOutpostConsumer into middleware

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix some layout issues

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add outpost controllers

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start testing audio things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix a bunch of things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add deps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix to work with outpost group

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add simple loadbalancing

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add simple reconnect

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* show reconnecting text

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix error when checking ports

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* move to providers

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add flow check to interface

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix go lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix rac app label

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix audio

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add logging

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* allow overriding all settings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix duplicate keyboard, debug high DPI

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-add deps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing __init__.py breaking model loading

I love python

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* bump successful ws connection to info

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* hide cursor since guac draws that

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add clipboard support (bidirectional)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make codespell not want to break the code

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* run pr comment in separate task

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start endpoint and property mapping stuff

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more endpoint things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated: fix event model_pk filtering with ints

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated: improve event display for changelog

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rebuild endpoint stuff again

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* idk special url

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more stuff, connect token with session

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add disconnect

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework disconnect

cleanly disconnect from guacd instead of just letting the connection timeout

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* clear cache when creating outpost

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* support host:port and fix protocol

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* center smaller viewport

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework connection to wait more and stop after some time

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add policy control to endpoints

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove provider protocol

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* don't switch to different outpost connection when already chosen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start using property mappings, add static settings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add some RAC mapping settings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start adding tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests for event changes

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests and fix issues found by said tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add preview banner, move endpoints to main page

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add locale

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* auto-select endpoint if only one is available

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* backport https://github.com/goauthentik/authentik/pull/7831 to rac

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* dont select property mappings on endpoints

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make table modal only load when opened

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only auto-redirect when open

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix web deps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* check for token expiry and terminate session

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-add endpoint name to title

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* disconnect connection when token is manually deleted

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add initial RAC docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add connection expiry setting to provider

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix flaky tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-12-30 21:33:14 +01:00 committed by GitHub
parent a365ec81f3
commit 240cf6dd94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 6398 additions and 144 deletions

View File

@ -9,3 +9,4 @@ blueprints/local
.git .git
!gen-ts-api/node_modules !gen-ts-api/node_modules
!gen-ts-api/dist/** !gen-ts-api/dist/**
!gen-go-api/

View File

@ -2,3 +2,4 @@ keypair
keypairs keypairs
hass hass
warmup warmup
ontext

View File

@ -249,12 +249,6 @@ jobs:
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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: build-arm64:
needs: ci-core-mark needs: ci-core-mark
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -303,3 +297,26 @@ jobs:
platforms: linux/arm64 platforms: linux/arm64
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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 }}

View File

@ -65,6 +65,7 @@ jobs:
- proxy - proxy
- ldap - ldap
- radius - radius
- rac
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload contianer images to ghcr.io # Needed to upload contianer images to ghcr.io
@ -119,6 +120,7 @@ jobs:
- proxy - proxy
- ldap - ldap
- radius - radius
- rac
goos: [linux] goos: [linux]
goarch: [amd64, arm64] goarch: [amd64, arm64]
steps: steps:

View File

@ -65,6 +65,7 @@ jobs:
- proxy - proxy
- ldap - ldap
- radius - radius
- rac
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5

View File

@ -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. lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
isort $(PY_SOURCES) isort $(PY_SOURCES)
black $(PY_SOURCES) black $(PY_SOURCES)
ruff $(PY_SOURCES) ruff --fix $(PY_SOURCES)
codespell -w $(CODESPELL_ARGS) codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources lint: ## Lint the python and golang sources

View File

@ -1,22 +1,29 @@
"""Channels base classes""" """Channels base classes"""
from channels.db import database_sync_to_async
from channels.exceptions import DenyConnection from channels.exceptions import DenyConnection
from channels.generic.websocket import JsonWebsocketConsumer
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.authentication import bearer_auth from authentik.api.authentication import bearer_auth
from authentik.core.models import User
LOGGER = get_logger() LOGGER = get_logger()
class AuthJsonConsumer(JsonWebsocketConsumer): class TokenOutpostMiddleware:
"""Authorize a client with a token""" """Authorize a client with a token"""
user: User def __init__(self, inner):
self.inner = inner
def connect(self): async def __call__(self, scope, receive, send):
headers = dict(self.scope["headers"]) 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: if b"authorization" not in headers:
LOGGER.warning("WS Request without authorization header") LOGGER.warning("WS Request without authorization header")
raise DenyConnection() raise DenyConnection()
@ -32,4 +39,4 @@ class AuthJsonConsumer(JsonWebsocketConsumer):
LOGGER.warning("Failed to authenticate", exc=exc) LOGGER.warning("Failed to authenticate", exc=exc)
raise DenyConnection() raise DenyConnection()
self.user = user scope["user"] = user

View File

@ -22,6 +22,7 @@ class InterfaceView(TemplateView):
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}" kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
kwargs["build"] = get_build_hash() kwargs["build"] = get_build_hash()
kwargs["url_kwargs"] = self.kwargs
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -1,6 +1,8 @@
"""Enterprise license policies""" """Enterprise license policies"""
from typing import Optional from typing import Optional
from django.utils.translation import gettext_lazy as _
from authentik.core.models import User, UserTypes from authentik.core.models import User, UserTypes
from authentik.enterprise.models import LicenseKey from authentik.enterprise.models import LicenseKey
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
@ -13,10 +15,10 @@ class EnterprisePolicyAccessView(PolicyAccessView):
def check_license(self): def check_license(self):
"""Check license""" """Check license"""
if not LicenseKey.get_total().is_valid(): 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: if self.request.user.type != UserTypes.INTERNAL:
return False return PolicyResult(False, _("Feature only accessible for internal users."))
return True return PolicyResult(True)
def user_has_access(self, user: Optional[User] = None) -> PolicyResult: def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
user = user or self.request.user user = user or self.request.user
@ -24,7 +26,7 @@ class EnterprisePolicyAccessView(PolicyAccessView):
request.http_request = self.request request.http_request = self.request
result = super().user_has_access(user) result = super().user_has_access(user)
enterprise_result = self.check_license() enterprise_result = self.check_license()
if not enterprise_result: if not enterprise_result.passing:
return enterprise_result return enterprise_result
return result return result

View File

@ -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)

View File

@ -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"]

View File

@ -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"]

View File

@ -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")

View File

@ -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/<our_channel_id>/
# 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()

View File

@ -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()

View File

@ -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 = []

View File

@ -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()]

View File

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

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,18 @@
{% extends "base/skeleton.html" %}
{% load static %}
{% block head %}
<script src="{% static 'dist/enterprise/rac/index.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% include "base/header_js.html" %}
{% endblock %}
{% block body %}
<ak-rac token="{{ url_kwargs.token }}" endpointName="{{ token.endpoint.name }}">
<ak-loading></ak-loading>
</ak-rac>
{% endblock %}

View File

@ -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)}/",
},
],
},
)

View File

@ -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",
},
)

View File

@ -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")

View File

@ -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/<slug:app>/<uuid:endpoint>/",
ensure_csrf_cookie(RACStartView.as_view()),
name="start",
),
path(
"if/rac/<str:token>/",
ensure_csrf_cookie(RACInterface.as_view()),
name="if-rac",
),
]
websocket_urlpatterns = [
path(
"ws/rac/<str:token>/",
ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi())))
),
),
path(
"ws/outpost_rac/<str:channel>/",
ChannelsLoggingMiddleware(TokenOutpostMiddleware(RACOutpostConsumer.as_asgi())),
),
]
api_urlpatterns = [
("providers/rac", RACProviderViewSet),
("propertymappings/rac", RACPropertyMappingViewSet),
("rac/endpoints", EndpointViewSet),
]

View File

@ -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)

View File

@ -10,3 +10,7 @@ CELERY_BEAT_SCHEDULE = {
"options": {"queue": "authentik_scheduled"}, "options": {"queue": "authentik_scheduled"},
} }
} }
INSTALLED_APPS = [
"authentik.enterprise.providers.rac",
]

View File

@ -6,6 +6,7 @@ import django_filters
from django.db.models.aggregates import Count from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform, KeyTransform from django.db.models.fields.json import KeyTextTransform, KeyTransform
from django.db.models.functions import ExtractDay, ExtractHour 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.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user 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 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""" UUIDField for this, as some models might not have a UUID PK"""
value = str(value).replace("-", "") 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: class Meta:
model = Event model = Event

View File

@ -1,4 +1,5 @@
"""Event API tests""" """Event API tests"""
from json import loads
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -11,6 +12,9 @@ from authentik.events.models import (
NotificationSeverity, NotificationSeverity,
TransportMode, 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): class TestEventsAPI(APITestCase):
@ -20,6 +24,25 @@ class TestEventsAPI(APITestCase):
self.user = create_test_admin_user() self.user = create_test_admin_user()
self.client.force_login(self.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): def test_top_n(self):
"""Test top_per_user""" """Test top_per_user"""
event = Event.new(EventAction.AUTHORIZE_APPLICATION) event = Event.new(EventAction.AUTHORIZE_APPLICATION)

View File

@ -17,6 +17,7 @@ from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.core.models import Provider 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.api.service_connections import ServiceConnectionSerializer
from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME
from authentik.outposts.models import ( from authentik.outposts.models import (
@ -63,6 +64,7 @@ class OutpostSerializer(ModelSerializer):
OutpostType.LDAP: LDAPProvider, OutpostType.LDAP: LDAPProvider,
OutpostType.PROXY: ProxyProvider, OutpostType.PROXY: ProxyProvider,
OutpostType.RADIUS: RadiusProvider, OutpostType.RADIUS: RadiusProvider,
OutpostType.RAC: RACProvider,
None: Provider, None: Provider,
} }
for provider in providers: for provider in providers:

View File

@ -6,16 +6,18 @@ from typing import Any, Optional
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.exceptions import DenyConnection from channels.exceptions import DenyConnection
from channels.generic.websocket import JsonWebsocketConsumer
from dacite.core import from_dict from dacite.core import from_dict
from dacite.data import Data from dacite.data import Data
from django.http.request import QueryDict
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from structlog.stdlib import BoundLogger, get_logger 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.apps import GAUGE_OUTPOSTS_CONNECTED, GAUGE_OUTPOSTS_LAST_UPDATE
from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState
OUTPOST_GROUP = "group_outpost_%(outpost_pk)s" OUTPOST_GROUP = "group_outpost_%(outpost_pk)s"
OUTPOST_GROUP_INSTANCE = "group_outpost_%(outpost_pk)s_%(instance)s"
class WebsocketMessageInstruction(IntEnum): class WebsocketMessageInstruction(IntEnum):
@ -42,25 +44,23 @@ class WebsocketMessage:
args: dict[str, Any] = field(default_factory=dict) 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""" """Handler for Outposts that connect over websockets for health checks and live updates"""
outpost: Optional[Outpost] = None outpost: Optional[Outpost] = None
logger: BoundLogger logger: BoundLogger
last_uid: Optional[str] = None instance_uid: Optional[str] = None
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.logger = get_logger() self.logger = get_logger()
def connect(self): def connect(self):
super().connect()
uuid = self.scope["url_route"]["kwargs"]["pk"] uuid = self.scope["url_route"]["kwargs"]["pk"]
user = self.scope["user"]
outpost = ( outpost = (
get_objects_for_user(self.user, "authentik_outposts.view_outpost") get_objects_for_user(user, "authentik_outposts.view_outpost").filter(pk=uuid).first()
.filter(pk=uuid)
.first()
) )
if not outpost: if not outpost:
raise DenyConnection() raise DenyConnection()
@ -71,13 +71,19 @@ class OutpostConsumer(AuthJsonConsumer):
self.logger.warning("runtime error during accept", exc=exc) self.logger.warning("runtime error during accept", exc=exc)
raise DenyConnection() raise DenyConnection()
self.outpost = outpost 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)( async_to_sync(self.channel_layer.group_add)(
OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name 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( GAUGE_OUTPOSTS_CONNECTED.labels(
outpost=self.outpost.name, outpost=self.outpost.name,
uid=self.last_uid, uid=self.instance_uid,
expected=self.outpost.config.kubernetes_replicas, expected=self.outpost.config.kubernetes_replicas,
).inc() ).inc()
@ -86,34 +92,37 @@ class OutpostConsumer(AuthJsonConsumer):
async_to_sync(self.channel_layer.group_discard)( async_to_sync(self.channel_layer.group_discard)(
OUTPOST_GROUP % {"outpost_pk": str(self.outpost.pk)}, self.channel_name 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( GAUGE_OUTPOSTS_CONNECTED.labels(
outpost=self.outpost.name, outpost=self.outpost.name,
uid=self.last_uid, uid=self.instance_uid,
expected=self.outpost.config.kubernetes_replicas, expected=self.outpost.config.kubernetes_replicas,
).dec() ).dec()
def receive_json(self, content: Data, **kwargs): def receive_json(self, content: Data, **kwargs):
msg = from_dict(WebsocketMessage, content) msg = from_dict(WebsocketMessage, content)
uid = msg.args.get("uuid", self.channel_name)
self.last_uid = uid
if not self.outpost: if not self.outpost:
raise DenyConnection() 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.last_seen = datetime.now()
state.hostname = msg.args.pop("hostname", "") state.hostname = msg.args.pop("hostname", "")
if msg.instruction == WebsocketMessageInstruction.HELLO: if msg.instruction == WebsocketMessageInstruction.HELLO:
state.version = msg.args.pop("version", None) state.version = msg.args.pop("version", None)
state.build_hash = msg.args.pop("buildHash", "") state.build_hash = msg.args.pop("buildHash", "")
state.args = msg.args state.args.update(msg.args)
elif msg.instruction == WebsocketMessageInstruction.ACK: elif msg.instruction == WebsocketMessageInstruction.ACK:
return return
GAUGE_OUTPOSTS_LAST_UPDATE.labels( GAUGE_OUTPOSTS_LAST_UPDATE.labels(
outpost=self.outpost.name, outpost=self.outpost.name,
uid=self.last_uid or "", uid=self.instance_uid or "",
version=state.version or "", version=state.version or "",
).set_to_current_time() ).set_to_current_time()
state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5)

View File

@ -1,5 +1,6 @@
"""k8s utils""" """k8s utils"""
from pathlib import Path from pathlib import Path
from typing import Optional
from kubernetes.client.models.v1_container_port import V1ContainerPort from kubernetes.client.models.v1_container_port import V1ContainerPort
from kubernetes.client.models.v1_service_port import V1ServicePort from kubernetes.client.models.v1_service_port import V1ServicePort
@ -37,9 +38,12 @@ def compare_port(
def compare_ports( 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""" """Compare ports of a list"""
if not current or not reference:
raise NeedsRecreate()
if len(current) != len(reference): if len(current) != len(reference):
raise NeedsRecreate() raise NeedsRecreate()
for port in reference: for port in reference:

View File

@ -81,7 +81,10 @@ class KubernetesController(BaseController):
def up(self): def up(self):
try: try:
for reconcile_key in self.reconcile_order: 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() reconciler.up()
except (OpenApiException, HTTPError, ServiceConnectionInvalid) as exc: except (OpenApiException, HTTPError, ServiceConnectionInvalid) as exc:
@ -95,7 +98,10 @@ class KubernetesController(BaseController):
all_logs += [f"{reconcile_key.title()}: Disabled"] all_logs += [f"{reconcile_key.title()}: Disabled"]
continue continue
with capture_logs() as logs: 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() reconciler.up()
all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs] all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs]
return all_logs return all_logs
@ -105,7 +111,10 @@ class KubernetesController(BaseController):
def down(self): def down(self):
try: try:
for reconcile_key in self.reconcile_order: 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) self.logger.debug("Tearing down object", name=reconcile_key)
reconciler.down() reconciler.down()
@ -120,7 +129,10 @@ class KubernetesController(BaseController):
all_logs += [f"{reconcile_key.title()}: Disabled"] all_logs += [f"{reconcile_key.title()}: Disabled"]
continue continue
with capture_logs() as logs: 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() reconciler.down()
all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs] all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs]
return all_logs return all_logs
@ -130,7 +142,10 @@ class KubernetesController(BaseController):
def get_static_deployment(self) -> str: def get_static_deployment(self) -> str:
documents = [] documents = []
for reconcile_key in self.reconcile_order: 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: if reconciler.noop:
continue continue
documents.append(reconciler.get_reference_object().to_dict()) documents.append(reconciler.get_reference_object().to_dict())

View File

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

View File

@ -90,11 +90,12 @@ class OutpostModel(Model):
class OutpostType(models.TextChoices): class OutpostType(models.TextChoices):
"""Outpost types, currently only the reverse proxy is available""" """Outpost types"""
PROXY = "proxy" PROXY = "proxy"
LDAP = "ldap" LDAP = "ldap"
RADIUS = "radius" RADIUS = "radius"
RAC = "rac"
def default_outpost_config(host: Optional[str] = None): def default_outpost_config(host: Optional[str] = None):
@ -459,7 +460,7 @@ class OutpostState:
def for_instance_uid(outpost: Outpost, uid: str) -> "OutpostState": def for_instance_uid(outpost: Outpost, uid: str) -> "OutpostState":
"""Get state for a single instance""" """Get state for a single instance"""
key = f"{outpost.state_cache_prefix}/{uid}" key = f"{outpost.state_cache_prefix}/{uid}"
default_data = {"uid": uid, "channel_ids": []} default_data = {"uid": uid}
data = cache.get(key, default_data) data = cache.get(key, default_data)
if isinstance(data, str): if isinstance(data, str):
cache.delete(key) cache.delete(key)

View File

@ -17,6 +17,8 @@ from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from yaml import safe_load 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 ( from authentik.events.monitored_tasks import (
MonitoredTask, MonitoredTask,
TaskResult, TaskResult,
@ -71,6 +73,11 @@ def controller_for_outpost(outpost: Outpost) -> Optional[type[BaseController]]:
return RadiusDockerController return RadiusDockerController
if isinstance(service_connection, KubernetesServiceConnection): if isinstance(service_connection, KubernetesServiceConnection):
return RadiusKubernetesController return RadiusKubernetesController
if outpost.type == OutpostType.RAC:
if isinstance(service_connection, DockerServiceConnection):
return RACDockerController
if isinstance(service_connection, KubernetesServiceConnection):
return RACKubernetesController
return None return None

View File

@ -1,6 +1,7 @@
"""Websocket tests""" """Websocket tests"""
from dataclasses import asdict from dataclasses import asdict
from channels.exceptions import DenyConnection
from channels.routing import URLRouter from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator from channels.testing import WebsocketCommunicator
from django.test import TransactionTestCase from django.test import TransactionTestCase
@ -35,6 +36,7 @@ class TestOutpostWS(TransactionTestCase):
communicator = WebsocketCommunicator( communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/" URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/"
) )
with self.assertRaises(DenyConnection):
connected, _ = await communicator.connect() connected, _ = await communicator.connect()
self.assertFalse(connected) self.assertFalse(connected)

View File

@ -1,6 +1,7 @@
"""Outpost Websocket URLS""" """Outpost Websocket URLS"""
from django.urls import path from django.urls import path
from authentik.core.channels import TokenOutpostMiddleware
from authentik.outposts.api.outposts import OutpostViewSet from authentik.outposts.api.outposts import OutpostViewSet
from authentik.outposts.api.service_connections import ( from authentik.outposts.api.service_connections import (
DockerServiceConnectionViewSet, DockerServiceConnectionViewSet,
@ -11,7 +12,10 @@ from authentik.outposts.consumer import OutpostConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware from authentik.root.middleware import ChannelsLoggingMiddleware
websocket_urlpatterns = [ websocket_urlpatterns = [
path("ws/outpost/<uuid:pk>/", ChannelsLoggingMiddleware(OutpostConsumer.as_asgi())), path(
"ws/outpost/<uuid:pk>/",
ChannelsLoggingMiddleware(TokenOutpostMiddleware(OutpostConsumer.as_asgi())),
),
] ]
api_urlpatterns = [ api_urlpatterns = [

View File

@ -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", "type": "object",
"required": [ "required": [
@ -3296,7 +3407,8 @@
"enum": [ "enum": [
"proxy", "proxy",
"ldap", "ldap",
"radius" "radius",
"rac"
], ],
"title": "Type" "title": "Type"
}, },
@ -3476,7 +3588,8 @@
"authentik.tenants", "authentik.tenants",
"authentik.blueprints", "authentik.blueprints",
"authentik.core", "authentik.core",
"authentik.enterprise" "authentik.enterprise",
"authentik.enterprise.providers.rac"
], ],
"title": "App", "title": "App",
"description": "Match events created by selected application. When left empty, all applications are matched." "description": "Match events created by selected application. When left empty, all applications are matched."
@ -3561,7 +3674,10 @@
"authentik_core.user", "authentik_core.user",
"authentik_core.application", "authentik_core.application",
"authentik_core.token", "authentik_core.token",
"authentik_enterprise.license" "authentik_enterprise.license",
"authentik_providers_rac.racprovider",
"authentik_providers_rac.endpoint",
"authentik_providers_rac.racpropertymapping"
], ],
"title": "Model", "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." "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": [] "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": { "model_authentik_blueprints.metaapplyblueprint": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -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"

93
cmd/rac/main.go Normal file
View File

@ -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)
}
}

1
go.mod
View File

@ -27,6 +27,7 @@ require (
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2023105.2 goauthentik.io/api/v3 v3.2023105.2
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.15.0 golang.org/x/oauth2 v0.15.0

8
go.sum
View File

@ -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/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 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 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 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 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= 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/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/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/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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 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/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 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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= 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 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/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/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.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= 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-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-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-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-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-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -159,8 +159,8 @@ func (a *APIController) AddRefreshHandler(handler func()) {
a.refreshHandlers = append(a.refreshHandlers, handler) a.refreshHandlers = append(a.refreshHandlers, handler)
} }
func (a *APIController) AddWSHandler(handler WSHandler) { func (a *APIController) Token() string {
a.wsHandlers = append(a.wsHandlers, handler) return a.token
} }
func (a *APIController) OnRefresh() error { func (a *APIController) OnRefresh() error {
@ -182,7 +182,7 @@ func (a *APIController) OnRefresh() error {
return err return err
} }
func (a *APIController) getWebsocketArgs() map[string]interface{} { func (a *APIController) getWebsocketPingArgs() map[string]interface{} {
args := map[string]interface{}{ args := map[string]interface{}{
"version": constants.VERSION, "version": constants.VERSION,
"buildHash": constants.BUILD("tagged"), "buildHash": constants.BUILD("tagged"),

View File

@ -18,6 +18,8 @@ import (
func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error { func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
pathTemplate := "%s://%s/ws/outpost/%s/?%s" pathTemplate := "%s://%s/ws/outpost/%s/?%s"
query := akURL.Query()
query.Set("instance_uuid", ac.instanceUUID.String())
scheme := strings.ReplaceAll(akURL.Scheme, "http", "ws") scheme := strings.ReplaceAll(akURL.Scheme, "http", "ws")
authHeader := fmt.Sprintf("Bearer %s", ac.token) 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 // Send hello message with our version
msg := websocketMessage{ msg := websocketMessage{
Instruction: WebsocketInstructionHello, Instruction: WebsocketInstructionHello,
Args: ac.getWebsocketArgs(), Args: ac.getWebsocketPingArgs(),
} }
err = ws.WriteJSON(msg) err = ws.WriteJSON(msg)
if err != nil { if err != nil {
@ -53,7 +55,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
return err return err
} }
ac.lastWsReconnect = time.Now() 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 return nil
} }
@ -157,23 +159,19 @@ func (ac *APIController) startWSHandler() {
func (ac *APIController) startWSHealth() { func (ac *APIController) startWSHealth() {
ticker := time.NewTicker(time.Second * 10) ticker := time.NewTicker(time.Second * 10)
for ; true; <-ticker.C { for ; true; <-ticker.C {
aliveMsg := websocketMessage{
Instruction: WebsocketInstructionHello,
Args: ac.getWebsocketArgs(),
}
if ac.wsConn == nil { if ac.wsConn == nil {
go ac.reconnectWS() go ac.reconnectWS()
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
continue continue
} }
err := ac.wsConn.WriteJSON(aliveMsg) err := ac.SendWSHello(map[string]interface{}{})
ac.logger.WithField("loop", "ws-health").Trace("hello'd")
if err != nil { if err != nil {
ac.logger.WithField("loop", "ws-health").WithError(err).Warning("ws write error") ac.logger.WithField("loop", "ws-health").WithError(err).Warning("ws write error")
go ac.reconnectWS() go ac.reconnectWS()
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
continue continue
} else { } else {
ac.logger.WithField("loop", "ws-health").Trace("hello'd")
ConnectionStatus.With(prometheus.Labels{ ConnectionStatus.With(prometheus.Labels{
"outpost_name": ac.Outpost.Name, "outpost_name": ac.Outpost.Name,
"outpost_type": ac.Server.Type(), "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
}

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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()
}

View File

@ -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")
}
}

126
internal/outpost/rac/rac.go Normal file
View File

@ -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
}

View File

@ -34,6 +34,11 @@ func (ws *WebServer) configureStatic() {
}) })
indexLessRouter.PathPrefix("/if/admin/assets").Handler(http.StripPrefix("/if/admin", distFs)) 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/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)) indexLessRouter.PathPrefix("/media/").Handler(http.StripPrefix("/media", fs))

38
rac.Dockerfile Normal file
View File

@ -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"]

1232
schema.yml

File diff suppressed because it is too large Load Diff

13
web/package-lock.json generated
View File

@ -35,6 +35,7 @@
"core-js": "^3.35.0", "core-js": "^3.35.0",
"country-flag-icons": "^1.5.9", "country-flag-icons": "^1.5.9",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"guacamole-common-js": "^1.5.0",
"lit": "^2.8.0", "lit": "^2.8.0",
"mermaid": "^10.6.1", "mermaid": "^10.6.1",
"rapidoc": "^9.3.4", "rapidoc": "^9.3.4",
@ -72,6 +73,7 @@
"@types/chart.js": "^2.9.41", "@types/chart.js": "^2.9.41",
"@types/codemirror": "5.60.15", "@types/codemirror": "5.60.15",
"@types/grecaptcha": "^3.0.7", "@types/grecaptcha": "^3.0.7",
"@types/guacamole-common-js": "1.3.2",
"@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0", "@typescript-eslint/parser": "^6.16.0",
"babel-plugin-macros": "^3.1.0", "babel-plugin-macros": "^3.1.0",
@ -7369,6 +7371,12 @@
"integrity": "sha512-ah5GDQfsiK3dnkaCbYcDFZXkZCG3o90VRu9hzXHnSe4kACrRB1KUI/ZyWHvYmqm1W5Tl8B5YxxT98uGTlkbf2Q==", "integrity": "sha512-ah5GDQfsiK3dnkaCbYcDFZXkZCG3o90VRu9hzXHnSe4kACrRB1KUI/ZyWHvYmqm1W5Tl8B5YxxT98uGTlkbf2Q==",
"dev": true "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": { "node_modules/@types/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz",
@ -11966,6 +11974,11 @@
"node": ">=6.0" "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": { "node_modules/gunzip-maybe": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz",

View File

@ -60,6 +60,7 @@
"core-js": "^3.35.0", "core-js": "^3.35.0",
"country-flag-icons": "^1.5.9", "country-flag-icons": "^1.5.9",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"guacamole-common-js": "^1.5.0",
"lit": "^2.8.0", "lit": "^2.8.0",
"mermaid": "^10.6.1", "mermaid": "^10.6.1",
"rapidoc": "^9.3.4", "rapidoc": "^9.3.4",
@ -97,6 +98,7 @@
"@types/chart.js": "^2.9.41", "@types/chart.js": "^2.9.41",
"@types/codemirror": "5.60.15", "@types/codemirror": "5.60.15",
"@types/grecaptcha": "^3.0.7", "@types/grecaptcha": "^3.0.7",
"@types/guacamole-common-js": "1.3.2",
"@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0", "@typescript-eslint/parser": "^6.16.0",
"babel-plugin-macros": "^3.1.0", "babel-plugin-macros": "^3.1.0",

View File

@ -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 [ export default [
POLY, POLY,
// Standalone // Standalone
@ -172,4 +187,6 @@ export default [
], ],
...defaultOptions, ...defaultOptions,
}, },
// Enterprise
...enterprise,
]; ];

View File

@ -21,6 +21,7 @@ import {
OutpostsServiceConnectionsAllListRequest, OutpostsServiceConnectionsAllListRequest,
PaginatedLDAPProviderList, PaginatedLDAPProviderList,
PaginatedProxyProviderList, PaginatedProxyProviderList,
PaginatedRACProviderList,
PaginatedRadiusProviderList, PaginatedRadiusProviderList,
ProvidersApi, ProvidersApi,
ServiceConnection, ServiceConnection,
@ -38,7 +39,8 @@ export class OutpostForm extends ModelForm<Outpost, string> {
providers?: providers?:
| PaginatedProxyProviderList | PaginatedProxyProviderList
| PaginatedLDAPProviderList | PaginatedLDAPProviderList
| PaginatedRadiusProviderList; | PaginatedRadiusProviderList
| PaginatedRACProviderList;
defaultConfig?: OutpostDefaultConfig; defaultConfig?: OutpostDefaultConfig;
@ -73,6 +75,12 @@ export class OutpostForm extends ModelForm<Outpost, string> {
applicationIsnull: false, applicationIsnull: false,
}); });
break; break;
case OutpostTypeEnum.Rac:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersRacList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.UnknownDefaultOpenApi: case OutpostTypeEnum.UnknownDefaultOpenApi:
this.providers = undefined; this.providers = undefined;
} }
@ -133,6 +141,12 @@ export class OutpostForm extends ModelForm<Outpost, string> {
> >
${msg("Radius")} ${msg("Radius")}
</option> </option>
<option
value=${OutpostTypeEnum.Rac}
?selected=${this.instance?.type === OutpostTypeEnum.Rac}
>
${msg("RAC")}
</option>
</select> </select>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Integration")} name="serviceConnection"> <ak-form-element-horizontal label=${msg("Integration")} name="serviceConnection">

View File

@ -41,6 +41,8 @@ export function TypeToLabel(type?: OutpostTypeEnum): string {
return msg("LDAP"); return msg("LDAP");
case OutpostTypeEnum.Radius: case OutpostTypeEnum.Radius:
return msg("Radius"); return msg("Radius");
case OutpostTypeEnum.Rac:
return msg("RAC");
case OutpostTypeEnum.UnknownDefaultOpenApi: case OutpostTypeEnum.UnknownDefaultOpenApi:
return msg("Unknown type"); return msg("Unknown type");
} }

View File

@ -1,5 +1,6 @@
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm"; import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification"; import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingRACForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm";
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm"; import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";

View File

@ -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<RACPropertyMapping, string> {
loadInstance(pk: string): Promise<RACPropertyMapping> {
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<RACPropertyMapping> {
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`
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("General settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Username")}
?required=${true}
name="staticSettings.username"
>
<input
type="text"
value="${ifDefined(this.instance?.staticSettings.username)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Password")}
?required=${true}
name="staticSettings.password"
>
<input
type="password"
value="${ifDefined(this.instance?.staticSettings.password)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("RDP settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="staticSettings.ignore-cert">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["ignore-cert"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Ignore server certificate")}</span
>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="staticSettings.enable-wallpaper">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["enable-wallpaper"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Enable wallpaper")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="staticSettings.enable-font-smoothing">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["enable-font-smoothing"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Enable font-smoothing")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="staticSettings.enable-full-window-drag">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(
this.instance?.staticSettings["enable-full-window-drag"],
false,
)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Enable full window dragging")}</span
>
</label>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Advanced settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Expression")}
?required=${true}
name="expression"
>
<ak-codemirror
mode=${CodeMirrorMode.Python}
value="${ifDefined(this.instance?.expression)}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">
${msg("Expression using Python.")}
<a
target="_blank"
href="${docLink(
"/docs/property-mappings/expression?utm_source=authentik",
)}"
>
${msg("See documentation for a list of all variables.")}
</a>
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
`;
}
}

View File

@ -1,5 +1,6 @@
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm"; import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification"; import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingRACForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm"; import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";
import "@goauthentik/admin/property-mappings/PropertyMappingTestForm"; import "@goauthentik/admin/property-mappings/PropertyMappingTestForm";

View File

@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/ProviderWizard";
import "@goauthentik/admin/providers/ldap/LDAPProviderForm"; import "@goauthentik/admin/providers/ldap/LDAPProviderForm";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
import "@goauthentik/admin/providers/proxy/ProxyProviderForm"; import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/providers/rac/RACProviderForm";
import "@goauthentik/admin/providers/radius/RadiusProviderForm"; import "@goauthentik/admin/providers/radius/RadiusProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderForm"; import "@goauthentik/admin/providers/saml/SAMLProviderForm";
import "@goauthentik/admin/providers/scim/SCIMProviderForm"; import "@goauthentik/admin/providers/scim/SCIMProviderForm";

View File

@ -1,6 +1,7 @@
import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage"; import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage"; import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage";
import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage"; import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage";
import "@goauthentik/admin/providers/rac/RACProviderViewPage";
import "@goauthentik/admin/providers/radius/RadiusProviderViewPage"; import "@goauthentik/admin/providers/radius/RadiusProviderViewPage";
import "@goauthentik/admin/providers/saml/SAMLProviderViewPage"; import "@goauthentik/admin/providers/saml/SAMLProviderViewPage";
import "@goauthentik/admin/providers/scim/SCIMProviderViewPage"; import "@goauthentik/admin/providers/scim/SCIMProviderViewPage";
@ -65,6 +66,10 @@ export class ProviderViewPage extends AKElement {
return html`<ak-provider-radius-view return html`<ak-provider-radius-view
providerID=${ifDefined(this.provider.pk)} providerID=${ifDefined(this.provider.pk)}
></ak-provider-radius-view>`; ></ak-provider-radius-view>`;
case "ak-provider-rac-form":
return html`<ak-provider-rac-view
providerID=${ifDefined(this.provider.pk)}
></ak-provider-rac-view>`;
default: default:
return html`<p>Invalid provider type ${this.provider?.component}</p>`; return html`<p>Invalid provider type ${this.provider?.component}</p>`;
} }

View File

@ -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<Endpoint, string> {
@property({ type: Number })
providerID?: number;
propertyMappings?: PaginatedRACPropertyMappingList;
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsRacList({
ordering: "name",
});
}
loadInstance(pk: string): Promise<Endpoint> {
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<Endpoint> {
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`
<ak-form-element-horizontal label=${msg("Name")} name="name" ?required=${true}>
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Protocol")} ?required=${true} name="protocol">
<ak-radio
.options=${[
{
label: msg("RDP"),
value: ProtocolEnum.Rdp,
},
{
label: msg("SSH"),
value: ProtocolEnum.Ssh,
},
{
label: msg("VNC"),
value: ProtocolEnum.Vnc,
},
]}
.value=${this.instance?.protocol}
>
</ak-radio>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Host")} name="host" ?required=${true}>
<input
type="text"
value="${ifDefined(this.instance?.host)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${msg("Hostname/IP to connect to.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Property mappings")}
?required=${true}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (this.instance?.propertyMappings) {
selected = Array.from(this.instance?.propertyMappings).some((su) => {
return su == mapping.pk;
});
}
return html`<option value=${ifDefined(mapping.pk)} ?selected=${selected}>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-group>
<span slot="header"> ${msg("Advanced settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("Settings")} name="settings">
<ak-codemirror
mode="yaml"
value="${YAML.stringify(first(this.instance?.settings, {}))}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">${msg("Connection settings.")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
`;
}
}

View File

@ -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<Endpoint> {
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<PaginatedResponse<Endpoint>> {
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`<ak-forms-delete-bulk
objectLabel=${msg("Endpoint(s)")}
.objects=${this.selectedElements}
.metadata=${(item: Endpoint) => {
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,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${msg("Delete")}
</button>
</ak-forms-delete-bulk>`;
}
row(item: Endpoint): TemplateResult[] {
return [
html`${item.name}`,
html`${item.host}`,
html`<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update Endpoint")} </span>
<ak-rac-endpoint-form slot="form" .instancePk=${item.pk}>
</ak-rac-endpoint-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.ProvidersRacEndpoint}
objectPk=${item.pk}
>
</ak-rbac-object-permission-modal>`,
];
}
renderExpanded(item: Endpoint): TemplateResult {
return html` <td></td>
<td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content">
<div class="pf-c-content">
<p>
${msg(
"These bindings control which users will have access to this endpoint. Users must also have access to the application.",
)}
</p>
<ak-bound-policies-list .target=${item.pk}> </ak-bound-policies-list>
</div>
</div>
</td>`;
}
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Endpoint")} </span>
<ak-rac-endpoint-form slot="form" .providerID=${this.provider?.pk}>
</ak-rac-endpoint-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>
`;
}
}

View File

@ -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<RACProvider, number> {
@state()
endpoints?: PaginatedEndpointList;
propertyMappings?: PaginatedRACPropertyMappingList;
async load(): Promise<void> {
this.endpoints = await new RacApi(DEFAULT_CONFIG).racEndpointsList({});
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsRacList({
ordering: "name",
});
}
async loadInstance(pk: number): Promise<RACProvider> {
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<RACProvider> {
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`
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")}
?required=${true}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${this.instance?.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when authorizing this provider.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Connection expiry")}
?required=${true}
name="connectionExpiry"
>
<input
type="text"
value="${first(this.instance?.connectionExpiry, "hours=8")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Determines how long a session lasts before being disconnected and requiring re-authorization.",
)}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Property mappings")}
?required=${true}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (this.instance?.propertyMappings) {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
return su == mapping.pk;
},
);
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Settings")} name="settings">
<ak-codemirror
mode="yaml"
value="${YAML.stringify(first(this.instance?.settings, {}))}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">${msg("Connection settings.")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
`;
}
}

View File

@ -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`<ak-tabs>
<section slot="page-overview" data-tab-title="${msg("Overview")}">
${this.renderTabOverview()}
</section>
<section
slot="page-changelog"
data-tab-title="${msg("Changelog")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${this.provider?.pk || ""}
targetModelName=${this.provider?.metaModelName || ""}
>
</ak-object-changelog>
</div>
</div>
</section>
<ak-rbac-object-permission-page
slot="page-permissions"
data-tab-title="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.ProvidersRacRacprovider}
objectPk=${this.provider.pk}
></ak-rbac-object-permission-page>
</ak-tabs>`;
}
renderTabOverview(): TemplateResult {
if (!this.provider) {
return html``;
}
return html` <div slot="header" class="pf-c-banner pf-m-info">
${msg("RAC is in preview.")}
<a href="mailto:hello+feature/rac@goauthentik.io">${msg("Send us feedback!")}</a>
</div>
${this.provider?.assignedApplicationName
? html``
: html`<div slot="header" class="pf-c-banner pf-m-warning">
${msg("Warning: Provider is not used by an Application.")}
</div>`}
${this.provider?.outpostSet.length < 1
? html`<div slot="header" class="pf-c-banner pf-m-warning">
${msg("Warning: Provider is not used by any Outpost.")}
</div>`
: html``}
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">
<dl class="pf-c-description-list pf-m-3-col-on-lg">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Name")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.name}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Assigned to application")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-provider-related-application
.provider=${this.provider}
></ak-provider-related-application>
</div>
</dd>
</div>
</dl>
</div>
<div class="pf-c-card__footer">
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update RAC Provider")} </span>
<ak-provider-rac-form slot="form" .instancePk=${this.provider.pk || 0}>
</ak-provider-rac-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Edit")}
</button>
</ak-forms-modal>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Endpoints")}</div>
<div class="pf-c-card__body">
<ak-rac-endpoint-list .provider=${this.provider}> </ak-rac-endpoint-list>
</div>
</div>
</div>`;
}
}

View File

@ -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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EventWithContext } from "@goauthentik/common/events"; import { EventWithContext } from "@goauthentik/common/events";
import { uiConfig } from "@goauthentik/common/ui/config"; import { uiConfig } from "@goauthentik/common/ui/config";
@ -73,7 +75,7 @@ export class ObjectChangelog extends Table<Event> {
row(item: EventWithContext): TemplateResult[] { row(item: EventWithContext): TemplateResult[] {
return [ return [
html`${item.action}`, html`${actionToLabel(item.action)}`,
html`<div>${item.user?.username}</div> html`<div>${item.user?.username}</div>
${item.user.on_behalf_of ${item.user.on_behalf_of
? html`<small> ? html`<small>
@ -81,7 +83,9 @@ export class ObjectChangelog extends Table<Event> {
</small>` </small>`
: html``}`, : html``}`,
html`<span>${item.created?.toLocaleString()}</span>`, html`<span>${item.created?.toLocaleString()}</span>`,
html`<span>${item.clientIp || msg("-")}</span>`, html`<div>${item.clientIp || msg("-")}</div>
<small>${EventGeo(item)}</small>`,
]; ];
} }

View File

@ -1,5 +1,5 @@
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { PFSize } from "@goauthentik/elements/Spinner"; import "@goauthentik/elements/EmptyState";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
@ -33,6 +33,8 @@ export class LoadingOverlay extends AKElement {
} }
render(): TemplateResult { render(): TemplateResult {
return html`<ak-spinner size=${PFSize.XLarge}></ak-spinner>`; return html`<ak-empty-state ?loading="${true}">
<slot name="body" slot="body"></slot>
</ak-empty-state>`;
} }
} }

View File

@ -27,6 +27,11 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Pagination, ResponseError } from "@goauthentik/api"; import { Pagination, ResponseError } from "@goauthentik/api";
export interface TableLike {
order?: string;
fetch: () => void;
}
export class TableColumn { export class TableColumn {
title: string; title: string;
orderBy?: string; orderBy?: string;
@ -38,7 +43,7 @@ export class TableColumn {
this.orderBy = orderBy; this.orderBy = orderBy;
} }
headerClickHandler(table: Table<unknown>): void { headerClickHandler(table: TableLike): void {
if (!this.orderBy) { if (!this.orderBy) {
return; return;
} }
@ -46,7 +51,7 @@ export class TableColumn {
table.fetch(); table.fetch();
} }
private getSortIndicator(table: Table<unknown>): string { private getSortIndicator(table: TableLike): string {
switch (table.order) { switch (table.order) {
case this.orderBy: case this.orderBy:
return "fa-long-arrow-alt-down"; return "fa-long-arrow-alt-down";
@ -57,7 +62,7 @@ export class TableColumn {
} }
} }
renderSortable(table: Table<unknown>): TemplateResult { renderSortable(table: TableLike): TemplateResult {
return html` <button return html` <button
class="pf-c-table__button" class="pf-c-table__button"
@click=${() => this.headerClickHandler(table)} @click=${() => this.headerClickHandler(table)}
@ -71,7 +76,7 @@ export class TableColumn {
</button>`; </button>`;
} }
render(table: Table<unknown>): TemplateResult { render(table: TableLike): TemplateResult {
const classes = { const classes = {
"pf-c-table__sort": !!this.orderBy, "pf-c-table__sort": !!this.orderBy,
"pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`, "pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`,
@ -89,7 +94,7 @@ export interface PaginatedResponse<T> {
results: Array<T>; results: Array<T>;
} }
export abstract class Table<T> extends AKElement { export abstract class Table<T> extends AKElement implements TableLike {
abstract apiEndpoint(page: number): Promise<PaginatedResponse<T>>; abstract apiEndpoint(page: number): Promise<PaginatedResponse<T>>;
abstract columns(): TableColumn[]; abstract columns(): TableColumn[];
abstract row(item: T): TemplateResult[]; abstract row(item: T): TemplateResult[];
@ -123,6 +128,12 @@ export abstract class Table<T> extends AKElement {
@property({ type: Boolean }) @property({ type: Boolean })
checkbox = false; checkbox = false;
@property({ type: Boolean })
clickable = false;
@property({ attribute: false })
clickHandler: (item: T) => void = () => {};
@property({ type: Boolean }) @property({ type: Boolean })
radioSelect = false; radioSelect = false;
@ -356,8 +367,12 @@ export abstract class Table<T> extends AKElement {
return html`<tbody role="rowgroup" class="${classMap(expandedClass)}"> return html`<tbody role="rowgroup" class="${classMap(expandedClass)}">
<tr <tr
role="row" role="row"
class="${this.checkbox ? "pf-m-hoverable" : ""}" class="${this.checkbox || this.clickable ? "pf-m-hoverable" : ""}"
@click=${itemSelectHandler} @click=${this.clickable
? () => {
this.clickHandler(item);
}
: itemSelectHandler}
> >
${this.checkbox ? renderCheckbox() : html``} ${this.checkbox ? renderCheckbox() : html``}
${this.expandable ? renderExpansion() : html``} ${this.expandable ? renderExpansion() : html``}

View File

@ -19,7 +19,18 @@ export abstract class TableModal<T> extends Table<T> {
size: PFSize = PFSize.Large; size: PFSize = PFSize.Large;
@property({ type: Boolean }) @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[] { static get styles(): CSSResult[] {
return super.styles.concat( return super.styles.concat(
@ -43,6 +54,13 @@ export abstract class TableModal<T> extends Table<T> {
}); });
} }
public async fetch(): Promise<void> {
if (!this.open) {
return;
}
return super.fetch();
}
resetForms(): void { resetForms(): void {
this.querySelectorAll<HTMLFormElement>("[slot=form]").forEach((form) => { this.querySelectorAll<HTMLFormElement>("[slot=form]").forEach((form) => {
if ("resetForm" in form) { if ("resetForm" in form) {

View File

@ -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<void> {
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<void> {
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`
<ak-loading-overlay>
<span slot="body">
${this.hasConnected
? html`${this.reconnectingMessage}`
: html`${msg("Connecting...")}`}
</span>
</ak-loading-overlay>
`
: html``}
<div class="container">${this.container}</div>
`;
}
}

View File

@ -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<Endpoint> {
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<PaginatedResponse<Endpoint>> {
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`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">${msg("Select endpoint to connect to")}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">${this.renderTable()}</section>
<footer class="pf-c-modal-box__footer">
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>
${msg("Cancel")}
</ak-spinner-button>
</footer>`;
}
}

View File

@ -3,6 +3,7 @@ import { truncateWords } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-app-icon"; import "@goauthentik/components/ak-app-icon";
import { AKElement, rootInterface } from "@goauthentik/elements/Base"; import { AKElement, rootInterface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Expand"; import "@goauthentik/elements/Expand";
import "@goauthentik/user/LibraryApplication/RACLaunchEndpointModal";
import { UserInterface } from "@goauthentik/user/UserInterface"; import { UserInterface } from "@goauthentik/user/UserInterface";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@ -85,6 +86,22 @@ export class LibraryApplication extends AKElement {
</ak-expand>`; </ak-expand>`;
} }
renderLaunch(): TemplateResult {
if (!this.application) {
return html``;
}
if (this.application?.launchUrl === "goauthentik.io://providers/rac/launch") {
return html`<ak-library-rac-endpoint-launch .app=${this.application}>
<a slot="trigger"> ${this.application.name} </a>
</ak-library-rac-endpoint-launch>`;
}
return html`<a
href="${ifDefined(this.application.launchUrl ?? "")}"
target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}"
>${this.application.name}</a
>`;
}
render(): TemplateResult { render(): TemplateResult {
if (!this.application) { if (!this.application) {
return html`<ak-spinner></ak-spinner>`; return html`<ak-spinner></ak-spinner>`;
@ -111,13 +128,7 @@ export class LibraryApplication extends AKElement {
<ak-app-icon size=${PFSize.Large} .app=${this.application}></ak-app-icon> <ak-app-icon size=${PFSize.Large} .app=${this.application}></ak-app-icon>
</a> </a>
</div> </div>
<div class="pf-c-card__title"> <div class="pf-c-card__title">${this.renderLaunch()}</div>
<a
href="${ifDefined(this.application.launchUrl ?? "")}"
target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}"
>${this.application.name}</a
>
</div>
<div class="expander"></div> <div class="expander"></div>
${expandable ? this.renderExpansion(this.application) : nothing} ${expandable ? this.renderExpansion(this.application) : nothing}
</div>`; </div>`;

View File

@ -2,10 +2,16 @@ import type { Application } from "@goauthentik/api";
const isFullUrlRe = new RegExp("://"); const isFullUrlRe = new RegExp("://");
const isHttpRe = new RegExp("http(s?)://"); const isHttpRe = new RegExp("http(s?)://");
const isAuthentikSpecialRe = new RegExp("goauthentik.io://");
const isNotFullUrl = (url: string) => !isFullUrlRe.test(url); const isNotFullUrl = (url: string) => !isFullUrlRe.test(url);
const isHttp = (url: string) => isHttpRe.test(url); const isHttp = (url: string) => isHttpRe.test(url);
const isAuthentikSpecial = (url: string) => isAuthentikSpecialRe.test(url);
export const appHasLaunchUrl = (app: Application) => { export const appHasLaunchUrl = (app: Application) => {
const url = app.launchUrl; const url = app.launchUrl;
return !!(typeof url === "string" && url !== "" && (isHttp(url) || isNotFullUrl(url))); return !!(
typeof url === "string" &&
url !== "" &&
(isHttp(url) || isNotFullUrl(url) || isAuthentikSpecial(url))
);
}; };

View File

@ -6117,6 +6117,126 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6393,6 +6393,126 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6033,6 +6033,126 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -1,4 +1,4 @@
<?xml version="1.0" ?><xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2"> <?xml version="1.0"?><xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file target-language="fr" source-language="en" original="lit-localize-inputs" datatype="plaintext"> <file target-language="fr" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body> <body>
<trans-unit id="s4caed5b7a7e5d89b"> <trans-unit id="s4caed5b7a7e5d89b">
@ -613,9 +613,9 @@ Il y a <x id="0" equiv-text="${ago}"/> jour(s)</target>
</trans-unit> </trans-unit>
<trans-unit id="saa0e2675da69651b"> <trans-unit id="saa0e2675da69651b">
<source>The URL &quot;<x id="0" equiv-text="${this.url}"/>&quot; was not found.</source> <source>The URL "<x id="0" equiv-text="${this.url}"/>" was not found.</source>
<target>L'URL &quot; <target>L'URL "
<x id="0" equiv-text="${this.url}"/>&quot; n'a pas été trouvée.</target> <x id="0" equiv-text="${this.url}"/>" n'a pas été trouvée.</target>
</trans-unit> </trans-unit>
<trans-unit id="s58cd9c2fe836d9c6"> <trans-unit id="s58cd9c2fe836d9c6">
@ -1057,8 +1057,8 @@ Il y a <x id="0" equiv-text="${ago}"/> jour(s)</target>
</trans-unit> </trans-unit>
<trans-unit id="sa8384c9c26731f83"> <trans-unit id="sa8384c9c26731f83">
<source>To allow any redirect URI, set this value to &quot;.*&quot;. Be aware of the possible security implications this can have.</source> <source>To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.</source>
<target>Pour permettre n'importe quelle URI de redirection, définissez cette valeur sur &quot;.*&quot;. Soyez conscient des possibles implications de sécurité que cela peut avoir.</target> <target>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.</target>
</trans-unit> </trans-unit>
<trans-unit id="s55787f4dfcdce52b"> <trans-unit id="s55787f4dfcdce52b">
@ -1630,7 +1630,7 @@ Il y a <x id="0" equiv-text="${ago}"/> jour(s)</target>
</trans-unit> </trans-unit>
<trans-unit id="s33ed903c210a6209"> <trans-unit id="s33ed903c210a6209">
<source>Token to authenticate with. Currently only bearer authentication is supported.</source> <source>Token to authenticate with. Currently only bearer authentication is supported.</source>
<target>Jeton d'authentification à utiliser. Actuellement, seule l'authentification &quot;bearer authentication&quot; est prise en charge.</target> <target>Jeton d'authentification à utiliser. Actuellement, seule l'authentification "bearer authentication" est prise en charge.</target>
</trans-unit> </trans-unit>
<trans-unit id="sfc8bb104e2c05af8"> <trans-unit id="sfc8bb104e2c05af8">
@ -1798,8 +1798,8 @@ Il y a <x id="0" equiv-text="${ago}"/> jour(s)</target>
</trans-unit> </trans-unit>
<trans-unit id="sa90b7809586c35ce"> <trans-unit id="sa90b7809586c35ce">
<source>Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon &quot;fa-test&quot;.</source> <source>Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test".</source>
<target>Entrez une URL complète, un chemin relatif ou utilisez 'fa://fa-test' pour utiliser l'icône Font Awesome &quot;fa-test&quot;.</target> <target>Entrez une URL complète, un chemin relatif ou utilisez 'fa://fa-test' pour utiliser l'icône Font Awesome "fa-test".</target>
</trans-unit> </trans-unit>
<trans-unit id="s0410779cb47de312"> <trans-unit id="s0410779cb47de312">
@ -2892,7 +2892,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s33683c3b1dbaf264"> <trans-unit id="s33683c3b1dbaf264">
<source>To use SSL instead, use 'ldaps://' and disable this option.</source> <source>To use SSL instead, use 'ldaps://' and disable this option.</source>
<target>Pour utiliser SSL à la base, utilisez &quot;ldaps://&quot; et désactviez cette option.</target> <target>Pour utiliser SSL à la base, utilisez "ldaps://" et désactviez cette option.</target>
</trans-unit> </trans-unit>
<trans-unit id="s2221fef80f4753a2"> <trans-unit id="s2221fef80f4753a2">
@ -2981,8 +2981,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s76768bebabb7d543"> <trans-unit id="s76768bebabb7d543">
<source>Field which contains members of a group. Note that if using the &quot;memberUid&quot; field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...'</source> <source>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,...'</source>
<target>Champ qui contient les membres d'un groupe. Si vous utilisez le champ &quot;memberUid&quot;, la valeur est censée contenir un nom distinctif relatif, par exemple 'memberUid=un-utilisateur' au lieu de 'memberUid=cn=un-utilisateur,ou=groups,...'</target> <target>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,...'</target>
</trans-unit> </trans-unit>
<trans-unit id="s026555347e589f0e"> <trans-unit id="s026555347e589f0e">
@ -3277,7 +3277,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s3198c384c2f68b08"> <trans-unit id="s3198c384c2f68b08">
<source>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.</source> <source>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.</source>
<target>Moment où les utilisateurs temporaires doivent être supprimés. Cela ne s'applique que si votre IDP utilise le format NameID &quot;transient&quot; et que l'utilisateur ne se déconnecte pas manuellement.</target> <target>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.</target>
</trans-unit> </trans-unit>
<trans-unit id="sb32e9c1faa0b8673"> <trans-unit id="sb32e9c1faa0b8673">
@ -3445,7 +3445,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s9f8aac89fe318acc"> <trans-unit id="s9f8aac89fe318acc">
<source>Optionally set the 'FriendlyName' value of the Assertion attribute.</source> <source>Optionally set the 'FriendlyName' value of the Assertion attribute.</source>
<target>Indiquer la valeur &quot;FriendlyName&quot; de l'attribut d'assertion (optionnel)</target> <target>Indiquer la valeur "FriendlyName" de l'attribut d'assertion (optionnel)</target>
</trans-unit> </trans-unit>
<trans-unit id="s851c108679653d2a"> <trans-unit id="s851c108679653d2a">
@ -3774,8 +3774,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s7b1fba26d245cb1c"> <trans-unit id="s7b1fba26d245cb1c">
<source>When using an external logging solution for archiving, this can be set to &quot;minutes=5&quot;.</source> <source>When using an external logging solution for archiving, this can be set to "minutes=5".</source>
<target>En cas d'utilisation d'une solution de journalisation externe pour l'archivage, cette valeur peut être fixée à &quot;minutes=5&quot;.</target> <target>En cas d'utilisation d'une solution de journalisation externe pour l'archivage, cette valeur peut être fixée à "minutes=5".</target>
</trans-unit> </trans-unit>
<trans-unit id="s44536d20bb5c8257"> <trans-unit id="s44536d20bb5c8257">
@ -3784,8 +3784,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s3bb51cabb02b997e"> <trans-unit id="s3bb51cabb02b997e">
<source>Format: &quot;weeks=3;days=2;hours=3,seconds=2&quot;.</source> <source>Format: "weeks=3;days=2;hours=3,seconds=2".</source>
<target>Format : &quot;weeks=3;days=2;hours=3,seconds=2&quot;.</target> <target>Format : "weeks=3;days=2;hours=3,seconds=2".</target>
</trans-unit> </trans-unit>
<trans-unit id="s04bfd02201db5ab8"> <trans-unit id="s04bfd02201db5ab8">
@ -3981,10 +3981,10 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="sa95a538bfbb86111"> <trans-unit id="sa95a538bfbb86111">
<source>Are you sure you want to update <x id="0" equiv-text="${this.objectLabel}"/> &quot;<x id="1" equiv-text="${this.obj?.name}"/>&quot;?</source> <source>Are you sure you want to update <x id="0" equiv-text="${this.objectLabel}"/> "<x id="1" equiv-text="${this.obj?.name}"/>"?</source>
<target>Êtes-vous sûr de vouloir mettre à jour <target>Êtes-vous sûr de vouloir mettre à jour
<x id="0" equiv-text="${this.objectLabel}"/>&quot; <x id="0" equiv-text="${this.objectLabel}"/>"
<x id="1" equiv-text="${this.obj?.name}"/>&quot; ?</target> <x id="1" equiv-text="${this.obj?.name}"/>" ?</target>
</trans-unit> </trans-unit>
<trans-unit id="sc92d7cfb6ee1fec6"> <trans-unit id="sc92d7cfb6ee1fec6">
@ -5070,8 +5070,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="sdf1d8edef27236f0"> <trans-unit id="sdf1d8edef27236f0">
<source>A &quot;roaming&quot; authenticator, like a YubiKey</source> <source>A "roaming" authenticator, like a YubiKey</source>
<target>Un authentificateur &quot;itinérant&quot;, comme une YubiKey</target> <target>Un authentificateur "itinérant", comme une YubiKey</target>
</trans-unit> </trans-unit>
<trans-unit id="sfffba7b23d8fb40c"> <trans-unit id="sfffba7b23d8fb40c">
@ -5396,7 +5396,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s5170f9ef331949c0"> <trans-unit id="s5170f9ef331949c0">
<source>Show arbitrary input fields to the user, for example during enrollment. Data is saved in the flow context under the 'prompt_data' variable.</source> <source>Show arbitrary input fields to the user, for example during enrollment. Data is saved in the flow context under the 'prompt_data' variable.</source>
<target>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 &quot;prompt_data&quot;.</target> <target>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".</target>
</trans-unit> </trans-unit>
<trans-unit id="s36cb242ac90353bc"> <trans-unit id="s36cb242ac90353bc">
@ -5405,10 +5405,10 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s2d5f69929bb7221d"> <trans-unit id="s2d5f69929bb7221d">
<source><x id="0" equiv-text="${prompt.name}"/> (&quot;<x id="1" equiv-text="${prompt.fieldKey}"/>&quot;, of type <x id="2" equiv-text="${prompt.type}"/>)</source> <source><x id="0" equiv-text="${prompt.name}"/> ("<x id="1" equiv-text="${prompt.fieldKey}"/>", of type <x id="2" equiv-text="${prompt.type}"/>)</source>
<target> <target>
<x id="0" equiv-text="${prompt.name}"/>(&quot; <x id="0" equiv-text="${prompt.name}"/>("
<x id="1" equiv-text="${prompt.fieldKey}"/>&quot;, de type <x id="1" equiv-text="${prompt.fieldKey}"/>", de type
<x id="2" equiv-text="${prompt.type}"/>)</target> <x id="2" equiv-text="${prompt.type}"/>)</target>
</trans-unit> </trans-unit>
@ -5457,8 +5457,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s1608b2f94fa0dbd4"> <trans-unit id="s1608b2f94fa0dbd4">
<source>If set to a duration above 0, the user will have the option to choose to &quot;stay signed in&quot;, which will extend their session by the time specified here.</source> <source>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.</source>
<target>Si défini à une durée supérieure à 0, l'utilisateur aura la possibilité de choisir de &quot;rester connecté&quot;, ce qui prolongera sa session jusqu'à la durée spécifiée ici.</target> <target>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.</target>
</trans-unit> </trans-unit>
<trans-unit id="s542a71bb8f41e057"> <trans-unit id="s542a71bb8f41e057">
@ -6242,7 +6242,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
</trans-unit> </trans-unit>
<trans-unit id="sa7fcf026bd25f231"> <trans-unit id="sa7fcf026bd25f231">
<source>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.</source> <source>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.</source>
<target>Peut être au format &quot;unix://&quot; pour une connexion à un service docker local, &quot;ssh://&quot; pour une connexion via SSH, ou &quot;https://:2376&quot; pour une connexion à un système distant.</target> <target>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.</target>
</trans-unit> </trans-unit>
<trans-unit id="saf1d289e3137c2ea"> <trans-unit id="saf1d289e3137c2ea">
@ -7549,7 +7549,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
</trans-unit> </trans-unit>
<trans-unit id="sff0ac1ace2d90709"> <trans-unit id="sff0ac1ace2d90709">
<source>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).</source> <source>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).</source>
<target>Utilisez ce fournisseur avec l'option &quot;auth_request&quot; de Nginx ou &quot;forwardAuth&quot; de Traefik. Chaque application/domaine a besoin de son propre fournisseur. De plus, sur chaque domaine, &quot;/outpost.goauthentik.io&quot; doit être routé vers le poste avancé (lorsque vous utilisez un poste avancé géré, cela est fait pour vous).</target> <target>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).</target>
</trans-unit> </trans-unit>
<trans-unit id="scb58b8a60cad8762"> <trans-unit id="scb58b8a60cad8762">
<source>Default relay state</source> <source>Default relay state</source>
@ -7963,7 +7963,7 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<target>Utilisateur créé et ajouté au groupe <x id="0" equiv-text="${this.group.name}"/> avec succès</target> <target>Utilisateur créé et ajouté au groupe <x id="0" equiv-text="${this.group.name}"/> avec succès</target>
</trans-unit> </trans-unit>
<trans-unit id="s824e0943a7104668"> <trans-unit id="s824e0943a7104668">
<source>This user will be added to the group &quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&quot;.</source> <source>This user will be added to the group "<x id="0" equiv-text="${this.targetGroup.name}"/>".</source>
<target>Cet utilisateur sera ajouté au groupe &amp;quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&amp;quot;.</target> <target>Cet utilisateur sera ajouté au groupe &amp;quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&amp;quot;.</target>
</trans-unit> </trans-unit>
<trans-unit id="s62e7f6ed7d9cb3ca"> <trans-unit id="s62e7f6ed7d9cb3ca">
@ -8041,6 +8041,126 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
<target>Forcer l'utilisation d'un avant-poste (le flux ne pourrait être exécuter que depuis un outpost).</target> <target>Forcer l'utilisation d'un avant-poste (le flux ne pourrait être exécuter que depuis un outpost).</target>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6241,6 +6241,126 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -7979,4 +7979,124 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit>
</body></file></xliff> </body></file></xliff>

View File

@ -6026,6 +6026,126 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -1,4 +1,4 @@
<?xml version="1.0" ?><xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2"> <?xml version="1.0"?><xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file target-language="zh-Hans" source-language="en" original="lit-localize-inputs" datatype="plaintext"> <file target-language="zh-Hans" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body> <body>
<trans-unit id="s4caed5b7a7e5d89b"> <trans-unit id="s4caed5b7a7e5d89b">
@ -613,9 +613,9 @@
</trans-unit> </trans-unit>
<trans-unit id="saa0e2675da69651b"> <trans-unit id="saa0e2675da69651b">
<source>The URL &quot;<x id="0" equiv-text="${this.url}"/>&quot; was not found.</source> <source>The URL "<x id="0" equiv-text="${this.url}"/>" was not found.</source>
<target>未找到 URL &quot; <target>未找到 URL "
<x id="0" equiv-text="${this.url}"/>&quot;。</target> <x id="0" equiv-text="${this.url}"/>"。</target>
</trans-unit> </trans-unit>
<trans-unit id="s58cd9c2fe836d9c6"> <trans-unit id="s58cd9c2fe836d9c6">
@ -1057,8 +1057,8 @@
</trans-unit> </trans-unit>
<trans-unit id="sa8384c9c26731f83"> <trans-unit id="sa8384c9c26731f83">
<source>To allow any redirect URI, set this value to &quot;.*&quot;. Be aware of the possible security implications this can have.</source> <source>To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.</source>
<target>要允许任何重定向 URI请将此值设置为 &quot;.*&quot;。请注意这可能带来的安全影响。</target> <target>要允许任何重定向 URI请将此值设置为 ".*"。请注意这可能带来的安全影响。</target>
</trans-unit> </trans-unit>
<trans-unit id="s55787f4dfcdce52b"> <trans-unit id="s55787f4dfcdce52b">
@ -1799,8 +1799,8 @@
</trans-unit> </trans-unit>
<trans-unit id="sa90b7809586c35ce"> <trans-unit id="sa90b7809586c35ce">
<source>Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon &quot;fa-test&quot;.</source> <source>Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test".</source>
<target>输入完整 URL、相对路径或者使用 'fa://fa-test' 来使用 Font Awesome 图标 &quot;fa-test&quot;。</target> <target>输入完整 URL、相对路径或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。</target>
</trans-unit> </trans-unit>
<trans-unit id="s0410779cb47de312"> <trans-unit id="s0410779cb47de312">
@ -2983,8 +2983,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s76768bebabb7d543"> <trans-unit id="s76768bebabb7d543">
<source>Field which contains members of a group. Note that if using the &quot;memberUid&quot; field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...'</source> <source>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,...'</source>
<target>包含组成员的字段。请注意,如果使用 &quot;memberUid&quot; 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...'</target> <target>包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...'</target>
</trans-unit> </trans-unit>
<trans-unit id="s026555347e589f0e"> <trans-unit id="s026555347e589f0e">
@ -3776,8 +3776,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s7b1fba26d245cb1c"> <trans-unit id="s7b1fba26d245cb1c">
<source>When using an external logging solution for archiving, this can be set to &quot;minutes=5&quot;.</source> <source>When using an external logging solution for archiving, this can be set to "minutes=5".</source>
<target>使用外部日志记录解决方案进行存档时,可以将其设置为 &quot;minutes=5&quot;。</target> <target>使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。</target>
</trans-unit> </trans-unit>
<trans-unit id="s44536d20bb5c8257"> <trans-unit id="s44536d20bb5c8257">
@ -3786,8 +3786,8 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s3bb51cabb02b997e"> <trans-unit id="s3bb51cabb02b997e">
<source>Format: &quot;weeks=3;days=2;hours=3,seconds=2&quot;.</source> <source>Format: "weeks=3;days=2;hours=3,seconds=2".</source>
<target>格式:&quot;weeks=3;days=2;hours=3,seconds=2&quot;。</target> <target>格式:"weeks=3;days=2;hours=3,seconds=2"。</target>
</trans-unit> </trans-unit>
<trans-unit id="s04bfd02201db5ab8"> <trans-unit id="s04bfd02201db5ab8">
@ -3983,10 +3983,10 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="sa95a538bfbb86111"> <trans-unit id="sa95a538bfbb86111">
<source>Are you sure you want to update <x id="0" equiv-text="${this.objectLabel}"/> &quot;<x id="1" equiv-text="${this.obj?.name}"/>&quot;?</source> <source>Are you sure you want to update <x id="0" equiv-text="${this.objectLabel}"/> "<x id="1" equiv-text="${this.obj?.name}"/>"?</source>
<target>您确定要更新 <target>您确定要更新
<x id="0" equiv-text="${this.objectLabel}"/>&quot; <x id="0" equiv-text="${this.objectLabel}"/>"
<x id="1" equiv-text="${this.obj?.name}"/>&quot; 吗?</target> <x id="1" equiv-text="${this.obj?.name}"/>" 吗?</target>
</trans-unit> </trans-unit>
<trans-unit id="sc92d7cfb6ee1fec6"> <trans-unit id="sc92d7cfb6ee1fec6">
@ -5072,7 +5072,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="sdf1d8edef27236f0"> <trans-unit id="sdf1d8edef27236f0">
<source>A &quot;roaming&quot; authenticator, like a YubiKey</source> <source>A "roaming" authenticator, like a YubiKey</source>
<target>像 YubiKey 这样的“漫游”身份验证器</target> <target>像 YubiKey 这样的“漫游”身份验证器</target>
</trans-unit> </trans-unit>
@ -5407,10 +5407,10 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s2d5f69929bb7221d"> <trans-unit id="s2d5f69929bb7221d">
<source><x id="0" equiv-text="${prompt.name}"/> (&quot;<x id="1" equiv-text="${prompt.fieldKey}"/>&quot;, of type <x id="2" equiv-text="${prompt.type}"/>)</source> <source><x id="0" equiv-text="${prompt.name}"/> ("<x id="1" equiv-text="${prompt.fieldKey}"/>", of type <x id="2" equiv-text="${prompt.type}"/>)</source>
<target> <target>
<x id="0" equiv-text="${prompt.name}"/>&quot; <x id="0" equiv-text="${prompt.name}"/>"
<x id="1" equiv-text="${prompt.fieldKey}"/>&quot;,类型为 <x id="1" equiv-text="${prompt.fieldKey}"/>",类型为
<x id="2" equiv-text="${prompt.type}"/></target> <x id="2" equiv-text="${prompt.type}"/></target>
</trans-unit> </trans-unit>
@ -5459,7 +5459,7 @@ doesn't pass when either or both of the selected options are equal or above the
</trans-unit> </trans-unit>
<trans-unit id="s1608b2f94fa0dbd4"> <trans-unit id="s1608b2f94fa0dbd4">
<source>If set to a duration above 0, the user will have the option to choose to &quot;stay signed in&quot;, which will extend their session by the time specified here.</source> <source>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.</source>
<target>如果设置时长大于 0用户可以选择“保持登录”选项这将使用户的会话延长此处设置的时间。</target> <target>如果设置时长大于 0用户可以选择“保持登录”选项这将使用户的会话延长此处设置的时间。</target>
</trans-unit> </trans-unit>
@ -7965,7 +7965,7 @@ Bindings to groups/users are checked against the user of the event.</source>
<target>成功创建用户并添加到组 <x id="0" equiv-text="${this.group.name}"/></target> <target>成功创建用户并添加到组 <x id="0" equiv-text="${this.group.name}"/></target>
</trans-unit> </trans-unit>
<trans-unit id="s824e0943a7104668"> <trans-unit id="s824e0943a7104668">
<source>This user will be added to the group &quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&quot;.</source> <source>This user will be added to the group "<x id="0" equiv-text="${this.targetGroup.name}"/>".</source>
<target>此用户将会被添加到组 &amp;quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&amp;quot;。</target> <target>此用户将会被添加到组 &amp;quot;<x id="0" equiv-text="${this.targetGroup.name}"/>&amp;quot;。</target>
</trans-unit> </trans-unit>
<trans-unit id="s62e7f6ed7d9cb3ca"> <trans-unit id="s62e7f6ed7d9cb3ca">
@ -8043,6 +8043,126 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
<target>需要前哨(流程只能从前哨执行)。</target> <target>需要前哨(流程只能从前哨执行)。</target>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6074,6 +6074,126 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -7963,6 +7963,126 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72"> <trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source> <source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
<trans-unit id="s3271da6c18c25b18">
<source>Connection settings.</source>
</trans-unit>
<trans-unit id="s2f4ca2148183d692">
<source>Successfully updated endpoint.</source>
</trans-unit>
<trans-unit id="s5adee855dbe191d9">
<source>Successfully created endpoint.</source>
</trans-unit>
<trans-unit id="s61e136c0658e27d5">
<source>Protocol</source>
</trans-unit>
<trans-unit id="sa062b019ff0c8809">
<source>RDP</source>
</trans-unit>
<trans-unit id="s97f9bf19fa5b57d1">
<source>SSH</source>
</trans-unit>
<trans-unit id="s7c100119e9ffcc32">
<source>VNC</source>
</trans-unit>
<trans-unit id="s6b05f9d8801fc14f">
<source>Host</source>
</trans-unit>
<trans-unit id="sb474f652a2c2fc76">
<source>Hostname/IP to connect to.</source>
</trans-unit>
<trans-unit id="s8276649077e8715c">
<source>Endpoint(s)</source>
</trans-unit>
<trans-unit id="sf1dabfe0fe8a75ad">
<source>Update Endpoint</source>
</trans-unit>
<trans-unit id="s008496c7716b9812">
<source>These bindings control which users will have access to this endpoint. Users must also have access to the application.</source>
</trans-unit>
<trans-unit id="s38e7cd1a24e70faa">
<source>Create Endpoint</source>
</trans-unit>
<trans-unit id="s4770c10e5b1c028c">
<source>RAC is in preview.</source>
</trans-unit>
<trans-unit id="s168565f5ac74a89f">
<source>Update RAC Provider</source>
</trans-unit>
<trans-unit id="s8465a2caa2d9ea5d">
<source>Endpoints</source>
</trans-unit>
<trans-unit id="s9857d883d8eb98fc">
<source>General settings</source>
</trans-unit>
<trans-unit id="sd2066881798a1b96">
<source>RDP settings</source>
</trans-unit>
<trans-unit id="sb864dc36a463a155">
<source>Ignore server certificate</source>
</trans-unit>
<trans-unit id="s20366a8d1eaaca54">
<source>Enable wallpaper</source>
</trans-unit>
<trans-unit id="s1e44c5350ef7598c">
<source>Enable font-smoothing</source>
</trans-unit>
<trans-unit id="s04ff5d6ae711e6d6">
<source>Enable full window dragging</source>
</trans-unit>
<trans-unit id="s663ccbfdf27e8dd0">
<source>Network binding</source>
</trans-unit>
<trans-unit id="sb108a06693c67753">
<source>No binding</source>
</trans-unit>
<trans-unit id="s5aab90c74f1233b8">
<source>Bind ASN</source>
</trans-unit>
<trans-unit id="s488303b048afe83b">
<source>Bind ASN and Network</source>
</trans-unit>
<trans-unit id="s3268dcfe0c8234dc">
<source>Bind ASN, Network and IP</source>
</trans-unit>
<trans-unit id="s226381aca231644f">
<source>Configure if sessions created by this stage should be bound to the Networks they were created in.</source>
</trans-unit>
<trans-unit id="s2555a1f20f3fd93e">
<source>GeoIP binding</source>
</trans-unit>
<trans-unit id="s3d63c78f93c9a92e">
<source>Bind Continent</source>
</trans-unit>
<trans-unit id="s395d5863b3a259b5">
<source>Bind Continent and Country</source>
</trans-unit>
<trans-unit id="s625ea0c32b4b136c">
<source>Bind Continent, Country and City</source>
</trans-unit>
<trans-unit id="s4bc7a1a88961be90">
<source>Configure if sessions created by this stage should be bound to their GeoIP-based location</source>
</trans-unit>
<trans-unit id="sa06cd519ff151b6d">
<source>RAC</source>
</trans-unit>
<trans-unit id="s28b99b59541f54ca">
<source>Connection failed after <x id="0" equiv-text="${this.connectionAttempt}"/> attempts.</source>
</trans-unit>
<trans-unit id="s7c7d956418e1c8c8">
<source>Re-connecting in <x id="0" equiv-text="${Math.max(1, delay / 1000)}"/> second(s).</source>
</trans-unit>
<trans-unit id="sfc003381f593d943">
<source>Connecting...</source>
</trans-unit>
<trans-unit id="s31aa94a0b3c7edb2">
<source>Select endpoint to connect to</source>
</trans-unit>
<trans-unit id="sa2ea0fcd3ffa80e0">
<source>Connection expiry</source>
</trans-unit>
<trans-unit id="s6dd297c217729828">
<source>Determines how long a session lasts before being disconnected and requiring re-authorization.</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -7,6 +7,7 @@ An outpost is a single deployment of an authentik component, which can be deploy
- [LDAP Provider](../providers/ldap/index.md) - [LDAP Provider](../providers/ldap/index.md)
- [Proxy Provider](../providers/proxy/index.md) - [Proxy Provider](../providers/proxy/index.md)
- [RADIUS Provider](../providers/radius/index.md) - [RADIUS Provider](../providers/radius/index.md)
- [RAC Provider](../providers/rac/index.md)
![](outposts.png) ![](outposts.png)

View File

@ -0,0 +1,47 @@
---
title: Remote Access (RAC) Provider
---
<span class="badge badge--primary">Enterprise</span>
---
:::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

View File

@ -2,10 +2,6 @@
title: Radius Provider 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. You can configure a Radius Provider for applications that don't support any other protocols or require Radius.
:::info :::info

View File

@ -110,6 +110,7 @@ const docsSidebar = {
items: ["providers/ldap/generic_setup"], items: ["providers/ldap/generic_setup"],
}, },
"providers/scim/index", "providers/scim/index",
"providers/rac/index",
], ],
}, },
{ {