This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
authentik/web/src/enterprise/rac/index.ts
Jens L 240cf6dd94
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>
2023-12-30 21:33:14 +01:00

325 lines
10 KiB
TypeScript

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>
`;
}
}